# Abilities
Source: https://docs.openhome.com/ability
This guide explains Abilities in OpenHome and how to manage, customize, and create them to extend Agent functionalities.
## Introduction to Abilities
Abilities are modular extensions that enhance the functionality of your OpenHome Agents. They act as plugins, enabling your Agents to perform specialized tasks, such as fetching data from the web, controlling smart devices, or executing complex commands tailored to your project's requirements.
With Abilities, you can:
* Add **Trigger Words** to define specific phrases or commands that activate a particular Ability.
* Use the **Marketplace** to explore and install community-created Abilities.
* Customize or create your own Abilities through the **Live Editor** or by uploading pre-built code.
## Managing Abilities
The **Abilities Dashboard** lets you view, manage, and customize all installed or created Abilities. Here’s what you can do:
### Tabs
* **My Abilities**: View Abilities you’ve created for your Agents.
* **Published Abilities**: Explore Abilities you’ve published to the Marketplace for others to use.
* **Installed Abilities**: Manage Abilities installed from the Marketplace or created by you.
* **Add Custom Ability**: Upload a `.zip` file containing your Ability code.
* **Live Editor**: Modify your Abilities in real-time, test them, and commit changes seamlessly.
### Ability Controls
* **Enable/Disable**: Toggle an Ability on or off as needed.
* **Agent/System Ability**: Specify whether an Ability is agent-specific or system-wide.
* **Trigger Words**: Define or edit words/phrases that activate the Ability.
* **Uninstall**: Remove an Ability from your system.
## Adding a New Ability
To create and configure a new Ability, follow these steps:
### 1. Access the Abilities Dashboard
* Navigate to the left sidebar, select **Create**, and choose **Abilities**.
### 2. Fill Out Ability Information
* **Name**: Enter a unique and descriptive name for your Ability.
* **Description**: Provide a brief overview of what the Ability does.
* **Image**: Upload an image to visually represent the Ability in your dashboard and the Marketplace.
### 3. Define Ability Behavior
* **Code Upload**: Upload a `.zip` file containing the Ability's code.
* **Trigger Words**: Add words or phrases that will activate the Ability.
* **Category**: Choose `Skill`, `Agent Controlled`, `Background Daemon`, or `Local` (when available).
* **Templates**: Select from built-in templates to simplify Ability creation.
### 4. Save and Finalize
* Click **Save Ability** to add it to your collection.
* Use the **Live Editor** for further enhancements or adjustments.
## Live Editor
The **Live Editor** provides tools to fine-tune, modify, and test your Abilities in real time. Features include:
* **File Management**: Create, delete, or modify Ability files.
* **Testing Tools**: Use the **Start Live Test** button to simulate the Ability's behavior.
* **Commit Changes**: Save modifications as a new release or revert to a previous version.
* **Trigger Keywords**: Edit trigger words directly in the editor to improve activation accuracy.
## Using Abilities in Agents
Abilities enhance the functionality of Agents, allowing them to:
* Respond dynamically to commands using **Trigger Words**.
* Perform specific tasks, such as retrieving weather updates, controlling devices, or generating quizzes.
* Seamlessly integrate with other components of the OpenHome ecosystem.
## Ability Categories
Every Ability falls into one of four categories: **Skill**, **Agent Controlled**, **Background Daemon**, or **Local**. See [Ability Types](/ability-types) for the full breakdown of when to use each, and [Background Abilities](/building-abilities/background-abilities) for the `background.py` pattern.
### Example Workflow
1. **Trigger Words**: A user speaks or types a command containing a pre-defined trigger word.
2. **Ability Activation**: The Agent processes the input and activates the corresponding Ability.
3. **Task Execution**: The Ability performs the task and returns the response.
4. **Dynamic Feedback**: The Agent adapts to the user's input and updates its interaction history.
## Abilities in the Marketplace
The **Marketplace** allows you to browse, install, and share Abilities created by the community.
### Features
* **Browse Abilities**: Discover new Abilities with user reviews and ratings.
* **Install/Uninstall**: Add or remove Abilities with a single click.
* **Search and Filters**: Find specific Abilities using keywords or filter by categories.
* **Featured Abilities**: Explore highlighted or trending Abilities to inspire new projects.
## Customization and Advanced Features
### Trigger Words
* Add, edit, or remove trigger words directly from the Ability settings or the Live Editor.
* Use to manage triggers effectively.
### Templates
* Built-in templates simplify the creation process.
* Customize templates to suit specific use cases or modify existing ones for advanced functionality.
## Conclusion
Abilities are the cornerstone of extending and enhancing OpenHome’s capabilities. Whether you’re building an IoT device controller, a productivity assistant, or a quiz generator, Abilities provide the flexibility to customize and scale your Agents to meet your project's unique needs. Leverage the Live Editor, Marketplace, and built-in templates to create innovative solutions and contribute to the growing OpenHome ecosystem.
> Start building and transforming your ideas into reality with OpenHome Abilities! 🎉
# Ability Types
Source: https://docs.openhome.com/ability-types
The four categories every OpenHome Ability falls into: Skill, Agent Controlled, Background Daemon, and Local.
Every Ability on OpenHome falls into one of four categories. Knowing which type you are building determines your architecture, your trigger strategy, and how users experience the feature.
## The four categories
| Category | Behavior |
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Skill** | Triggered by a hotword. Runs a flow, exits. The original ability pattern. |
| **Agent Controlled** | The Agent decides when to invoke it: data lookups, tool use, delegated actions. No explicit trigger. |
| **Background Daemon** | Starts automatically when a call begins and runs continuously for the whole session. Used for monitoring, alarms, ambient intelligence. |
| [**Local**](/guides/getting-started/local-ability) | Runs as a Local Ability on OpenHome DevKit hardware. |
# Agent Memory & Context Injection
Source: https://docs.openhome.com/agent_memory_context_injection
How persistent `.md` files are injected into the Agent prompt, and how to build abilities around that behavior.
# Agent Memory & Context Injection
The `MemorySnapshotCapabilityBackground` runs as a background daemon and continuously updates Agent context.
## Overview
* The background reads user-level persistent files and injects every `.md` file into the Agent prompt.
* This makes `.md` files the primary path for ambient context injection.
* The Profile UI exposes persistent memory files (`user_profile.md`, `user_summary.md`) as editable content.
## Background Cycle
The background runs sequentially every \~60-90 seconds:
1. `save_user_summary()` updates `user_summary.md`.
2. `save_user_profile()` updates `user_profile.md`.
3. `update_agent_prompt()` scans persistent storage (`in_ability_directory=False`) and injects all `.md` files into the live Agent prompt.
Latency from file write to Agent behavior change is typically 60-90 seconds.
## Context Injection Rule
If an ability writes a persistent `.md` file, the Agent will see it on the next background cycle.
* `.md`: injected into Agent prompt
* `.json`, `.txt`, `.log`, `.csv`, `.yaml`, `.yml`: stored only, not injected
## Required Write Pattern for Replaceable Context Files
`write_file()` appends by default. For context files that represent current state, always delete then write:
```python theme={"system"}
async def write_context_file(self, filename: str, content: str):
exists = await self.capability_worker.check_if_file_exists(filename, in_ability_directory=False)
if exists:
await self.capability_worker.delete_file(filename, in_ability_directory=False)
await self.capability_worker.write_file(filename, content, in_ability_directory=False)
```
Use this pattern for files like `audio_emotion.md`, `upcoming_schedule.md`, and `home_state.md`.
## Reserved Files
Do not write these from custom abilities:
* `user_profile.md`
* `user_summary.md`
These are owned by the memory background.
## Naming and Size Guidance
* Namespace filenames by feature (`audio_emotion.md`, not `context.md`).
* Keep each injected `.md` file concise (target: under 200 words).
* Write current state, not long history logs.
## Stale Context Cleanup
For ephemeral daemon context, clear stale `.md` state at daemon startup before first processing cycle:
```python theme={"system"}
exists = await self.capability_worker.check_if_file_exists("audio_emotion.md", in_ability_directory=False)
if exists:
await self.capability_worker.delete_file("audio_emotion.md", in_ability_directory=False)
```
This prevents old context from being injected after reconnect.
## Dual-Path Response Model
* Ambient path: write `.md` files for background-based prompt injection.
* Urgent path: call `send_interrupt_signal()` first, then `speak()` for immediate intervention.
```python theme={"system"}
await self.capability_worker.send_interrupt_signal()
await self.capability_worker.speak("You seem stressed. Want a quick breather?")
```
## Profile Tab: Editable Persistent Memory Files
In Dashboard **Profile**, persistent memory files are visible and editable:
* `user_profile.md`
* `user_summary.md`
These files are part of the same persistent memory system consumed by the background. Changes made in Profile are reflected in Agent context after the next background cycle.
# Agents
Source: https://docs.openhome.com/agents
How to create, configure, and manage Agents in OpenHome.
At the heart of the OpenHome ecosystem are **Agents**, customizable AI voice characters designed for specific tasks and applications. Each Agent is characterized by several key attributes, including:
* **Description and Purpose**: Defines the Agent’s role and how it behaves within the chosen LLM.
* **Voice**: You can tailor the voice to best represent the Agent, aligning with your preferences or project needs.
* **Dynamic Feedback**: Agents evolve based on user interactions, learning from conversations to provide more personalized responses over time. OpenHome's **DynamicAgentConstructor** enables Agents to evolve with every conversation. They adapt to your conversation history, preferences, and personal style, creating an ever-improving interaction experience. This ensures that the conversation isn’t just accurate but also deeply personalized, making every interaction feel more intuitive.
### How Agents Work
The core architecture revolves around three key modules: **STT Transcription** (Speech-to-Text), **TTT Processing** (Text-to-Text), and **Voice TTS** (Text-to-Speech). These modules work together to create rich, adaptive experiences powered by customizable "Agents".
#### Workflow
1. **Speech Input:** The system listens for voice commands, initiated by a cold start message.
2. **STT Transcription:** The Speech-to-Text (STT) module converts speech into text.
3. **LLM Processing:** The transcribed text is processed by the designated LLM (the “brains” of the assistant, the large language model), which generates a relevant response based on the user's input and conversation history. OpenHome supports over 20 different LLMs.
4. **TTS Synthesis:** Once a response is generated, it’s converted back into speech using customizable voices, creating a natural, interactive experience. This module brings the conversation to life with fluent, human-like speech.
## How to Create & Manage Agents
The **Agents dashboard** allows you to view, manage, and create custom AI Agents. You can also customize the interaction experience by adding new voices.
### Creating New Agent
To get started, visit the Agents dashboard and select the button at the top-right.
By default, **Create Personality** opens in **Quick Creation** mode.
* In **Quick Creation**, fill in:
* **Avatar** (upload or **Generate with AI**)
* **Name**
* **Starting Message**
* **Description**
* **Personality Category**
* **Voice Identity** (you can use **Clone voice** and preview)
* Click `Save Personality` to create the new personality.
* Need advanced options? Click **Switch to PRO Mode** and follow the full Pro flow in [Quickstart: Create New Agent](/quickstart#create-new-agent).
### Managing Agents
The **Agents page** allows you to view, manage, and organize all the Agents you have created or installed from the Marketplace. Each Agent is displayed as a card, showing key details such as its description, last updated timestamp, and options for further customization or interaction.
* **Search**: Use the search bar at the top to quickly find a specific Agent by name.
* **Status Filter**: The dropdown menu in the top right allows you to filter Agents by status, including Published, Unpublished, Default, and Installed.
* **Start Conversation**: Open a conversation with the selected Agent.
* **Share**: Share this Agent with someone, allowing them to interact with it.
* **Edit**:Click the Edit button to modify your Agent.
* **Delete**: Delete any created or installed Agents.
* **Ratings & Review**: Review and rate the Agent on the marketplace.
### Adding a New Voice
To add your own custom voice, visit the Agents dashboard, and select the button at the top right of the page and input the following information:
* **Name**: Enter the name of the new voice you want to create. This helps identify the voice within your list of options.
* **Description**: Provide a brief description of the voice, including details like its tone, accent, or intended use. This helps others understand what the voice is designed for when interacting with it.
* **Voice ID**: Input the Voice ID provided by your chosen TTS (Text-to-Speech) provider. This ID links the voice you've uploaded to the provider.
* Select to finalize and add the new voice.
* If you change your mind, select to discard your voice.
### Uploading Your Voice
* **Generating a Voice ID**: Before adding a new voice in OpenHome, you’ll need to upload your custom voice to your preferred TTS provider (e.g., Eleven Labs). Once uploaded, you’ll receive a Voice ID from the provider, which you can enter in the Voice ID field here.
* **Updating API Key**: Ensure your API key for the TTS provider is up to date. This key is necessary for OpenHome to communicate with the TTS service and use your custom voice for Agents. You can update or manage your API key in the **API Keys settings** section.
# AI Twin
Source: https://docs.openhome.com/ai-twin
Build a voice-powered version of yourself with AI Twin by OpenHome.
Welcome to AI Twin by OpenHome, your creative space to build a voice-powered version of yourself. Below you'll find helpful information for getting started, managing your Twin, and troubleshooting common issues.
## Frequently Asked Questions
It's easy. Just launch the app and follow the guided creation process. You'll answer a few voice and agent questions, and we'll automatically generate your Twin's voice, background, and bio based on your answers.
Yes. Once you've built your AI Twin, make sure to create an account or log in to save your progress. If you skip this step, your Twin may be lost.
Definitely. After you log in, go to your Settings page. From there, you can update your Twin's:
* Twin Bio
* Twin Background
* Username
## Basic Troubleshooting
Make sure you were logged in when building your Twin. If not, you'll need to rebuild it. Always log in to save. If you're not logged in, close and open the app to restart the Twin building process should you get stuck.
Check your phone's microphone permissions in your device settings. Make sure the AI Twin app is allowed to access your mic. Be in a quiet environment and speak clearly when answering questions.
Try closing and restarting the app. If the issue continues, delete and reinstall or update the app from your app store.
## App Features
Build your Twin by answering short voice prompts. No forms or typing required.
Your answers shape a unique, intelligent agent that talks back.
Update your Twin's bio, background, and agent at any time in the settings menu.
Log in with your account and talk to your Twin across multiple devices.
# API Reference
Source: https://docs.openhome.com/api-sdk/api-reference
HTTP endpoints for managing Agents, Abilities, and keys from scripts, CI, or the OpenHome CLI.
Most developers build on OpenHome through the [Dashboard](https://app.openhome.com/). This reference is for when you need programmatic access — scripting, CI pipelines, or tools like the [OpenHome CLI](/guides/getting-started/cli).
## Authentication
All endpoints require an OpenHome API key. Get one from [Settings → API Keys](https://app.openhome.com/dashboard/settings).
**The Try-It playground saves your key in your browser's local storage.** If you enter your real production key to test an endpoint, clear it when you're done:
* Open browser DevTools → **Application** tab → **Local Storage** → this site → delete the entry, or
* Use a separate test key (revocable from [Dashboard → Settings → API Keys](https://app.openhome.com/dashboard/settings)) for playground testing and rotate it regularly.
Never test with a key that's also used in production on a shared or public machine.
Two auth styles depending on the endpoint family:
| Style | Used by | How |
| ------------------------ | ------------------------------------------------ | ------------------------------------------------ |
| **`X-API-KEY` header** | `/api/capabilities/*` and `/api/personalities/*` | Set `X-API-KEY: YOUR_KEY` |
| **`api_key` body field** | `/api/sdk/*` | Include `"api_key": "YOUR_KEY"` in the JSON body |
## Endpoints
`GET /api/personalities/get-all-personalities/`
`PUT /api/personalities/edit-personality/`
`POST /api/capabilities/add-capability/`
`GET /api/capabilities/get-capability/{id}/`
`GET /api/capabilities/get-all-capabilities/`
`GET /api/capabilities/get-installed-capabilities/`
`PUT /api/capabilities/edit-installed-capability/{id}/`
`POST /api/capabilities/delete-capability/`
# Delete Ability
Source: https://docs.openhome.com/api-sdk/endpoints/delete-ability
POST https://app.openhome.com/api/capabilities/delete-capability/
Permanently delete one or more Abilities from your account.
Your OpenHome API key.
The Ability IDs to delete. Pass a single ID to delete one Ability, or several to delete multiple in one call.
```bash cURL theme={"system"}
curl -X POST https://app.openhome.com/api/capabilities/delete-capability/ \
-H "X-API-KEY: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"capability_ids": [123]}'
```
```python Python theme={"system"}
import requests
requests.post(
"https://app.openhome.com/api/capabilities/delete-capability/",
headers={"X-API-KEY": "YOUR_KEY"},
json={"capability_ids": [123]},
)
```
# Edit Agent
Source: https://docs.openhome.com/api-sdk/endpoints/edit-agent
PUT https://app.openhome.com/api/personalities/edit-personality/
Update an Agent. The most common use is assigning or unassigning Abilities.
Your OpenHome API key.
Full list of Ability (capability) IDs the Agent should have. This replaces the current list — send `[]` to unassign all.
```bash cURL theme={"system"}
curl -X PUT https://app.openhome.com/api/personalities/edit-personality/ \
-H "X-API-KEY: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"matching_capabilities": [1, 2, 3]}'
```
```python Python theme={"system"}
import requests
requests.put(
"https://app.openhome.com/api/personalities/edit-personality/",
headers={"X-API-KEY": "YOUR_KEY"},
json={"matching_capabilities": [1, 2, 3]},
)
```
# Get Ability
Source: https://docs.openhome.com/api-sdk/endpoints/get-ability
Retrieve a single Ability by its ID.
## Endpoint
```
GET https://app.openhome.com/api/capabilities/get-capability/{capability_id}/
```
## Headers
Your OpenHome API key.
## Path parameters
The Ability ID.
## Example request
```bash cURL theme={"system"}
curl https://app.openhome.com/api/capabilities/get-capability/123/ \
-H "X-API-KEY: YOUR_KEY"
```
```python Python theme={"system"}
import requests
requests.get(
"https://app.openhome.com/api/capabilities/get-capability/123/",
headers={"X-API-KEY": "YOUR_KEY"},
)
```
# List Abilities
Source: https://docs.openhome.com/api-sdk/endpoints/list-abilities
GET https://app.openhome.com/api/capabilities/get-all-capabilities/
Returns every Ability on your account.
Your OpenHome API key.
```bash cURL theme={"system"}
curl https://app.openhome.com/api/capabilities/get-all-capabilities/ \
-H "X-API-KEY: YOUR_KEY"
```
```python Python theme={"system"}
import requests
requests.get(
"https://app.openhome.com/api/capabilities/get-all-capabilities/",
headers={"X-API-KEY": "YOUR_KEY"},
)
```
# List Agents
Source: https://docs.openhome.com/api-sdk/endpoints/list-agents
GET https://app.openhome.com/api/personalities/get-all-personalities/
Returns the Agents on your account.
Your OpenHome API key.
Include each Agent's image URL in the response.
Array of Agent objects.
Agent ID.
Agent name.
URL of the Agent's image (only present when `with_image=true`).
```bash cURL theme={"system"}
curl "https://app.openhome.com/api/personalities/get-all-personalities/?with_image=true" \
-H "X-API-KEY: YOUR_KEY"
```
```python Python theme={"system"}
import requests
requests.get(
"https://app.openhome.com/api/personalities/get-all-personalities/",
headers={"X-API-KEY": "YOUR_KEY"},
params={"with_image": True},
)
```
```json 200 theme={"system"}
{
"personalities": [
{
"id": 4727,
"name": "Default Agent",
"image": "https://app.openhome.com/media/user_personalities/32/image/test.png"
},
{
"id": 5823,
"name": "Custom Assistant",
"image": "https://app.openhome.com/media/user_personalities/32/image/test.png"
}
]
}
```
# List Installed Abilities
Source: https://docs.openhome.com/api-sdk/endpoints/list-installed-abilities
GET https://app.openhome.com/api/capabilities/get-installed-capabilities/
Returns the Abilities currently installed on the authenticated account.
Your OpenHome API key.
```bash cURL theme={"system"}
curl https://app.openhome.com/api/capabilities/get-installed-capabilities/ \
-H "X-API-KEY: YOUR_KEY"
```
```python Python theme={"system"}
import requests
requests.get(
"https://app.openhome.com/api/capabilities/get-installed-capabilities/",
headers={"X-API-KEY": "YOUR_KEY"},
)
```
# Enable or Disable Ability
Source: https://docs.openhome.com/api-sdk/endpoints/toggle-ability
PUT https://app.openhome.com/api/capabilities/edit-installed-capability/{installed_cap_id}/
Toggle an installed Ability on or off without deleting it.
Your OpenHome API key.
The installed-capability ID.
`true` to enable, `false` to disable.
The Ability's trigger words. This must not be empty. Send the Ability's existing trigger words so they are preserved when toggling.
```bash cURL theme={"system"}
curl -X PUT https://app.openhome.com/api/capabilities/edit-installed-capability/456/ \
-H "X-API-KEY: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled": true, "trigger_words": ["weather", "forecast"]}'
```
```python Python theme={"system"}
import requests
requests.put(
"https://app.openhome.com/api/capabilities/edit-installed-capability/456/",
headers={"X-API-KEY": "YOUR_KEY"},
json={"enabled": True, "trigger_words": ["weather", "forecast"]},
)
```
# Upload Ability
Source: https://docs.openhome.com/api-sdk/endpoints/upload-ability
Create a new Ability on your account by uploading a zipped package.
This endpoint uses `multipart/form-data` with a file upload. Use the cURL or Python example below. Names must be unique on your account — calling this with an existing name returns `Ability with same name already exists`. Delete the existing Ability first to replace it.
## Endpoint
```
POST https://app.openhome.com/api/capabilities/add-capability/
```
Send the request as `multipart/form-data`.
## Headers
Your OpenHome API key.
## Form fields
Ability name. Must be unique on your account.
One of `skill`, `brain_skill`, `background_daemon`, or `local`.
One-line description.
Comma-separated trigger phrases. Example: `hey skill, activate skill`.
Zipped Ability package. Accepted content types: `application/zip`, `application/x-zip`, `application/x-zip-compressed`, `application/octet-stream`.
Optional icon.
## Example request
```bash cURL theme={"system"}
curl -X POST https://app.openhome.com/api/capabilities/add-capability/ \
-H "X-API-KEY: YOUR_KEY" \
-F "name=My Skill" \
-F "category=skill" \
-F "description=Greets the user" \
-F "trigger_words=hey skill, activate skill" \
-F "zip_file=@./my-skill.zip"
```
```python Python theme={"system"}
import requests
with open("my-skill.zip", "rb") as f:
requests.post(
"https://app.openhome.com/api/capabilities/add-capability/",
headers={"X-API-KEY": "YOUR_KEY"},
data={
"name": "My Skill",
"category": "skill",
"description": "Greets the user",
"trigger_words": "hey skill, activate skill",
},
files={"zip_file": f},
)
```
# SDK Reference
Source: https://docs.openhome.com/api-sdk/sdk-reference
The toolkit for building OpenHome Abilities — methods, models, prompt patterns, architecture, and sandbox rules.
Every method below is accessed through `self.capability_worker` (the SDK) or `self.worker` (the Agent). This is the complete toolkit for building any Ability.
## Twenty essential SDK methods
| # | Method | What it does | Async? | Object |
| -- | ------------------------------------------------ | -------------------------------------------------------------------------- | ------ | ------------ |
| 1 | `speak(text)` | Speak text aloud using the Agent's default voice | Yes | `cap_worker` |
| 2 | `text_to_speech(text, voice_id)` | Speak with a specific ElevenLabs voice ID | Yes | `cap_worker` |
| 3 | `user_response()` | Wait for the user's next spoken input, returns string | Yes | `cap_worker` |
| 4 | `wait_for_complete_transcription()` | Wait until the user fully finishes speaking before returning | Yes | `cap_worker` |
| 5 | `run_io_loop(text)` | Speak text, then wait for user reply (speak + listen combo) | Yes | `cap_worker` |
| 6 | `run_confirmation_loop(text)` | Speak text, loop until user says yes or no. Returns bool | Yes | `cap_worker` |
| 7 | `text_to_text_response(prompt, history, system)` | Generate LLM text response. **The only sync method — no `await`** | **No** | `cap_worker` |
| 8 | `start_audio_recording()` | Begin recording from device mic (runs in background) | No | `cap_worker` |
| 9 | `stop_audio_recording()` | Stop the current mic recording | No | `cap_worker` |
| 10 | `get_audio_recording()` | Returns recorded audio as `.wav` bytes | No | `cap_worker` |
| 11 | `play_from_audio_file(filename)` | Play an audio file bundled with your Ability | Yes | `cap_worker` |
| 12 | `play_audio(file_content)` | Play audio from bytes or file-like object | Yes | `cap_worker` |
| 13 | `resume_normal_flow()` | Hand control back to the Personality. **Required** on every `main.py` exit | No | `cap_worker` |
| 14 | `send_interrupt_signal()` | Stop current assistant output. Call before daemon `speak()` | Yes | `cap_worker` |
| 15 | `write_file(name, content, temp)` | Write or append to persistent or session file storage | Yes | `cap_worker` |
| 16 | `read_file(name, temp)` | Read contents of a stored file as string | Yes | `cap_worker` |
| 17 | `check_if_file_exists(name, temp)` | Returns bool — always check before reading | Yes | `cap_worker` |
| 18 | `get_full_message_history()` | Full conversation transcript from current session | No | `cap_worker` |
| 19 | `get_timezone()` | User's timezone string, e.g. `"America/Chicago"` | No | `cap_worker` |
| 20 | `session_tasks.create(coro)` | Launch a managed async task. Use this instead of `asyncio.create_task` | No | `worker` |
### Bonus methods
`delete_file()` · `get_audio_recording_length()` · `flush_audio_recording()` · `send_data_over_websocket()` · `send_devkit_action()` · [`get_token()`](/building-abilities/how-to-build#reading-linked-account-tokens-with-get-token) · `get_region_data()` · `stream_init()` / `stream_end()` · [`stream_music_from_url()`](/building-abilities/how-to-build#music-playback) · `pause_music()` / `stop_music()` / `resume_music()` / `flush_music()` · `create_key()` / `update_key()` / `delete_key()` / `get_single_key()` / `get_all_keys()` · `update_personality_agent_prompt()` · [`exec_local_command()`](/building-abilities/openclaw) · `session_tasks.sleep()` · [`session_tasks.get()`](/building-abilities/how-to-build#making-http-requests)
### Music playback
`stream_music_from_url()` is for audio that runs for minutes and can be interrupted by voice. It plays a progressive mp3 link, blocks until playback genuinely ends, and reports why it ended in `result["outcome"]`. Your Ability owns only the branch on `outcome`. The audio pipeline, the device buffer, music mode, and the recovery afterwards are all inside the call.
**Play a track:**
```python theme={"system"}
result = await self.capability_worker.stream_music_from_url(
url,
"", # Authorization header value, "" if none
announce="Playing Blinding Lights.")
result["outcome"] # "finished" | "paused" | "stopped" | "unplayable" | "error"
result["position"] # float seconds heard, for logs
result["sent"] # int bytes delivered, for logs
```
`url` and `auth` are both required. `auth` is positional rather than defaulted on purpose. Assuming a stream link from an authenticated API is self-signed is how every track ends up `unplayable`.
**Pause and resume** is the same call again with the same `url`. No position, byte offset or duration is tracked. The engine reads the byte rate off the mp3's own frame header, and holds the pause against the url. Only `"paused"` continues the loop:
```python theme={"system"}
url = self.stream_url(track) # once per track, not per pass
announced = False
while True:
result = await self.capability_worker.stream_music_from_url(
url,
f"Bearer {self.api_key}", # "" if the host wants none
announce=f"Playing {track['title']}." if not announced else "")
announced = True
if result["outcome"] != "paused": # finished / stopped / error / unplayable
break
reply = await self.capability_worker.run_io_loop("Paused. Say resume or stop.")
if "resume" not in (reply or "").lower():
break
```
**To end playback from your own code** (a timer, a device event), `pause_music()` and `stop_music()` set the same events the platform sets, and the live call returns `"paused"` or `"stopped"`.
While a stream is live the Ability is in music mode: it receives no transcriptions, and must not call `speak()` or `run_io_loop()`. Speak before the call or after it returns. However playback ends, including the error path, the client is audible again and the platform is listening when the call returns.
See [Music Playback](/building-abilities/how-to-build#music-playback) for the full parameter and `outcome` tables.
***
## OpenRouter models
Use OpenRouter (openrouter.ai) as a single API endpoint to access any model. Pick by job: fast/cheap for routing, multimodal for audio, high-quality for user-facing responses.
| # | Model | Speed | Best for | Notes |
| -- | ----------------------------------- | --------- | ----------------- | -------------------------------------------------- |
| 1 | `google/gemini-2.0-flash-001` | Very fast | Routing, general | Great all-rounder, cheap, supports audio input |
| 2 | `google/gemini-2.5-flash-preview` | Fast | Deep reasoning | Thinking model, more capable than 2.0 Flash |
| 3 | `google/gemini-3-flash-preview` | Fast | Audio analysis | Latest generation, strong multimodal |
| 4 | `anthropic/claude-sonnet-4` | Medium | Quality responses | Excellent reasoning and tone control |
| 5 | `anthropic/claude-haiku-4-5` | Very fast | Routing, speed | Cheapest Anthropic option, solid quality |
| 6 | `openai/gpt-4o` | Medium | General, vision | Strong all-rounder with multimodal support |
| 7 | `openai/gpt-4o-mini` | Very fast | Routing, cheap | Fast and affordable for utility tasks |
| 8 | `meta-llama/llama-3.3-70b-instruct` | Fast | Open source | Great quality, fast via Groq/Cerebras |
| 9 | `deepseek/deepseek-r1` | Slow | Deep analysis | Reasoning model, best for complex background tasks |
| 10 | `mistralai/mistral-large-latest` | Medium | Multilingual | Strong European language support |
Mix models in a single Ability. Use fast/cheap (Gemini Flash, Haiku, GPT-4o-mini) for intent routing and keyword extraction. Use quality models (Claude Sonnet, GPT-4o) for user-facing spoken responses. Use multimodal (Gemini Flash/Pro) for audio analysis.
***
## Battle-tested prompt patterns
Each prompt below is designed for **voice output** — short, spoken, no markdown.
### 1. Intent router (JSON classification)
```
Classify this user input. Return ONLY valid JSON, nothing else.
{"intent": "weather|timer|music|chat", "confidence": 0.0-1.0}
User: {user_input}
```
Use with `text_to_text_response()`. Always strip markdown fences before parsing JSON.
### 2. Persona system prompt (voice character)
```
You are Marcus, a brutally honest venture capitalist. You speak in short,
punchy sentences. 2-4 sentences max. No markdown, no lists. This is spoken
aloud, not a blog post. Never say "as a VC" or "in my experience".
```
Use as `system_prompt` parameter. Keep persona prompts specific about length, format, and forbidden phrases.
### 3. Audio analysis — Pass 1 (general)
```
You are an expert audio analyst. Listen carefully to this recording and
provide a detailed analysis. Describe: what type of sound, environment,
acoustic characteristics (rhythm, pitch, texture, layers), and anything
unusual. Do NOT address the user. Write as pure third-person analysis.
```
Use with an OpenRouter audio-capable model (Gemini Flash/Pro). Send alongside base64 WAV.
### 4. Audio analysis — Pass 2 (specific with context)
```
Here is a general analysis already completed:
{general_analysis}
Now answer this specific question about the audio: "{user_question}"
Be precise. Use timestamps and specific details where possible.
```
Inject Pass 1 results as context. The two-pass pattern hides latency while providing deep answers.
### 5. Conversational response (with history)
```
You are [persona] in conversation about [topic].
--- ANALYSIS ---
{analysis}
--- CONVERSATION ---
{chat_history}
The user just said: "{user_input}"
Respond in 1-3 sentences, spoken aloud. Don't repeat yourself.
```
Inject accumulated analysis + full chat history. Context compounds with every turn.
### 6. LLM-driven time parser (alarm pattern)
```
You are an alarm time parser. Current: {now_iso}, Timezone: {tz_name}
If day/date missing, respond: QUESTION:at what day?
If time missing, respond: QUESTION:at what time?
When complete, return ONLY valid JSON:
{"target_iso": "...", "human_time": "...", "timezone": "..."}
```
Loop up to 6 rounds. If response starts with `QUESTION:`, ask the user and continue.
### 7. Grocery list extractor
```
Extract a grocery list from this transcript. Organize by section
(produce, dairy, meat, pantry). Deduplicate and clean up.
Transcript: {transcript}
Grocery List:
```
Turns stream-of-consciousness rambling into structured, organized output.
### 8. Restart vs continue intent detection
```
A user is in a conversation about a sound they played. Determine if
they want to listen to a NEW sound (restart) or are asking about the
current sound (continue).
User said: "{user_input}"
Return ONLY valid JSON: {"intent": "restart or continue", "confidence": 0.0}
```
Two-tier approach: check fast keywords first, fall back to LLM only for ambiguous input.
### 9. Contextual voice assistant
```
You are a concise voice assistant for [domain] management.
USER: {name} | LOCATION: {city} | TIME: {current_time}
Rules: Keep responses to 2-4 sentences max. Be conversational.
Never say "as an AI" or "I don't have feelings".
```
Inject user context (name, location, time) for natural, personalized responses.
### 10. Farewell / exit summary
```
The conversation is ending. Here's the full history:
{history}
Give a 1-2 sentence parting thought. If the idea improved during the
conversation, acknowledge it. If not, give one last honest nudge.
```
Generate a contextual goodbye instead of a generic sign-off. Makes exits feel natural.
***
## Architecture patterns
### Ability categories
See [Ability Types](/ability-types) for the full breakdown.
| Category | Behavior |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Skill** | Trigger-word Ability. User says hotword → runs a flow → exits with `resume_normal_flow()` |
| **Agent Controlled** | The Agent auto-triggers it when it can't fully answer or needs to delegate an action |
| **Background Daemon** | Auto-starts on session. Runs continuously. Works in sleep mode. See [Background Abilities](/building-abilities/background-abilities) |
| **Local** | Runs on DevKit outside the sandbox via `devkit_functions.py`. Enables direct hardware control and restricted libraries. See [Local Ability](/building-abilities/local-ability) |
### File structure
| Type | Files | Description |
| -------------------- | --------------------------- | ------------------------------------------------------------------------------- |
| Standard interactive | `main.py` only | Triggered by hotwords, runs, exits with `resume_normal_flow()` |
| Standalone daemon | `background.py` only | Auto-starts on session. Background monitoring, logging, note-taking |
| Interactive + daemon | `main.py` + `background.py` | Interactive handles user requests. Daemon monitors. Coordinate via shared files |
### `main.py` vs `background.py`
| 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 | Yes |
***
## Core patterns
### The loop template (multi-turn conversation)
Greet → loop (listen → process → respond) → exit on command. Most common pattern for interactive Abilities.
```python theme={"system"}
while True:
user_input = await self.capability_worker.user_response()
if any(word in user_input.lower() for word in EXIT_WORDS):
break
response = self.capability_worker.text_to_text_response(user_input)
await self.capability_worker.speak(response)
self.capability_worker.resume_normal_flow()
```
### The two-pass analysis pattern
Pass 1 fires in background immediately (general analysis). While it runs, the Ability talks to the user. Pass 2 fires with Pass 1 context injected, answering the user's specific question from depth.
* **Pass 1:** fire-and-forget via `session_tasks.create(asyncio.to_thread(run_general))`
* Talk to user while Pass 1 runs (hides 10–15s of latency)
* **Pass 2:** inject Pass 1 results as context, answer the specific question
* Each follow-up turn fires a background re-analysis, enriching future turns
### The rolling window pattern (ambient audio)
For always-on audio monitoring. Continuously record, slice the last N seconds, send to model on a fixed cadence. **Fire-and-forget** — never `await` inside the loop.
* 10-second window, 3-second refresh cadence
* API call fires as background task, poll loop never waits
* Responses arrive asynchronously and log themselves
### The coordination pattern (`main.py` + `background.py`)
Main writes data to persistent file storage. Background polls that file on a timer and acts on it. This is how alarms, reminders, and scheduled tasks work.
* `main.py`: parse user input, write to JSON file, `resume_normal_flow()`
* `background.py`: poll file every 15–30 seconds, check conditions, act
* Use **delete + write** for JSON files — append corrupts JSON
* Call `send_interrupt_signal()` before speaking from a daemon
### The pending state pattern (multi-step collection)
Track what info you're waiting for with a dictionary. Each loop iteration checks pending state first and routes input to the correct handler.
```python theme={"system"}
self.pending_create = {"waiting_for": "title"}
# Next turn: user gives title → update to {"title": "X", "waiting_for": "time"}
# Next turn: user gives time → all info collected, execute action
```
***
## Sandbox rules
Breaking these rules will fail the Ability scanner.
* Never write `register_capability()` by hand — always use the platform tag
* No `import os`, no `import json` at the top level outside the register block
* No raw `open()` — use `play_from_audio_file()` for audio, the file storage API for data
* No `signal` module — even in docstrings or comments, the scanner catches it
* Always call `resume_normal_flow()` on every exit path in `main.py`
* Use `session_tasks.sleep()` and `session_tasks.create()` — not raw `asyncio`
* Wrap all blocking HTTP calls in `asyncio.to_thread()`
* No `print()` — use `editor_logging_handler`
* Blocked imports: `redis`, `connection_manager`, `user_config`, `exec()`, `eval()`, `pickle`
***
## Voice UX best practices
* Keep `speak()` to **1–2 sentences**. This is voice, not text
* **Fill the silence**: say "One sec" before any API call over 1 second
* Read your `speak()` strings out loud before shipping
* Handle messy voice input: use the LLM to extract clean data from noisy transcription
* Offer exit at every loop iteration: check for "done", "stop", "quit", etc.
* Use `run_confirmation_loop()` before destructive actions (send, delete, cancel)
* **Idle detection**: 1 empty response = keep going, 2 in a row = offer to leave
* Namespace your filenames: `smarthub_prefs.json` not `data.json`
* JSON persistence: always **delete + write** (append corrupts JSON)
* API calls: always set `timeout=10`, wrap in `try/except`, speak errors to user
# Websocket Streaming
Source: https://docs.openhome.com/api-sdk/websocket
How to use our websocket in your application to call your agent
Use the desired agent id under your account in the WebSocket URL.
```wss theme={"system"}
wss://app.openhome.com/websocket/voice-stream/OPENHOME_API_KEY/PERSONALITY_ID
```
Here is an example:
```wss theme={"system"}
wss://app.openhome.com/websocket/voice-stream/xyzsdsadasannfma/4727
```
### OPENHOME\_API\_KEY
Checkout API Docs to get OPENHOME\_API\_KEY
### AGENT\_ID
Checkout Get Agents section to get your account's agent ids.
* **4727**: Represents the agent ID.
* Set the agent ID to `0` to skip this part. This will start the call with the default agent, OpenHome.
***
## WebSocket Flow
### Audio Data Format
Audio data sent to the WebSocket must adhere to the following specifications:
* Format: **16-bit PCM**
* Sample Rate: **16000 Hz**
* Encoding: **Base64**
Ensure that the audio is converted to this format before sending it to the WebSocket.
***
### WebSocket Message Structure
#### Client to Server Messages
1. **User to Server Text Messages**
```json theme={"system"}
{
"data": "MESSAGE_CONTEXT",
"type": "transcribed"
}
```
2. **User to Server Audio Messages**
```json theme={"system"}
{
"data": "BASE64_ENCODED_AUDIO_MESSAGES",
"type": "audio"
}
```
### Server to Client Messages
1. Server to User Text Messages
```json theme={"system"}
{
"data": {
"content": "MESSAGE CONTENT",
"live": true,
"role": "assistant"
},
"type": "message"
}
```
**live: true** indicates live transcription or response logs.
**final: true** provides finalized transcription and response messages.
2. Server to User Audio Messages
```json theme={"system"}
{
"data": "BASE64_ENCODED_AUDIO_MESSAGES",
"type": "audio"
}
```
## EXAMPLES
Find More Examples on GitHub
```HTML HTML theme={"system"}
Audio Stream
Connection Status: Disconnected
Microphone Status: Off
```
```py Python theme={"system"}
import websockets
import asyncio
import base64
import json
import subprocess
import pyaudio
import os
import numpy as np
OPENHOME_API_KEY = "abcxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyz"
class VoiceStreamer:
def __init__(self, speaker_type="mpv"):
self.api_key = OPENHOME_API_KEY
self.server_url = f"https://app.openhome.com/websocket/voice-stream/{self.api_key}/0"
self.frames_per_buffer = 3200
self.format = pyaudio.paInt16
self.channels = 1
self.rate = 16000
self.speaker_type = speaker_type
self.websocket = None
self.should_send_audio = True
# Noise reduction parameters
self.alpha = 0.95 # Smoothing factor
self.prev_noise_power = None
# INITIALIZE PYAUDIO
self.py_audio_obj = pyaudio.PyAudio()
self.stream = self._create_stream()
# INITIALIZE PLACEHOLDER FOR MPV
self.mpv_process = None
self.speaker = None
# self.playback_complete = asyncio.Event()
self.is_speaking = False
def _create_stream(self):
"""Create the microphone audio stream with better error handling."""
try:
# Get default input device info
device_info = self.py_audio_obj.get_default_input_device_info()
# Adjust parameters based on device capabilities
supported_rate = min(int(device_info['defaultSampleRate']), self.rate)
stream = self.py_audio_obj.open(
format=self.format,
channels=self.channels,
rate=supported_rate,
input=True,
frames_per_buffer=self.frames_per_buffer,
stream_callback=None,
start=False # Don't start immediately
)
# Test the stream
stream.start_stream()
test_data = stream.read(self.frames_per_buffer)
if not test_data:
raise IOError("No audio data received")
return stream
except Exception as e:
print(f"Error creating audio stream: {e}")
# Try fallback parameters
return self.py_audio_obj.open(
format=self.format,
channels=1,
rate=16000,
input=True,
frames_per_buffer=1024
)
def process_audio(self, audio_data):
audio_array = np.frombuffer(audio_data, dtype=np.int16)
audio_float = audio_array.astype(np.float32) / 32768.0
# RMS energy calculation
rms = np.sqrt(np.mean(audio_float**2))
# Initialize state variables if they don't exist
if not hasattr(self, 'energy_history'):
self.energy_history = []
self.baseline_energy = None
self.speaking_threshold = 0.03
self.last_voice_activity = False
self.voice_holdoff_counter = 0
# Update energy history
self.energy_history.append(rms)
if len(self.energy_history) > 30:
self.energy_history.pop(0)
# Calculate dynamic threshold with different bases for speaking/not speaking
if self.is_speaking:
# Much higher threshold when bot is speaking
dynamic_threshold = np.mean(self.energy_history) * 1.8
else:
# Normal threshold when bot is not speaking
dynamic_threshold = np.mean(self.energy_history) * 1.2
# Significant audio detection with hysteresis
if not self.last_voice_activity:
# Higher threshold to start detection
significant_audio = rms > dynamic_threshold * 1.2
else:
# Lower threshold to continue detection
significant_audio = rms > dynamic_threshold * 0.8
# Voice activity state machine with holdoff
if significant_audio:
self.last_voice_activity = True
self.voice_holdoff_counter = 0
else:
self.voice_holdoff_counter += 1
if self.voice_holdoff_counter > 10: # Adjust holdoff period as needed
self.last_voice_activity = False
# If bot is speaking, require much stronger user audio
if self.is_speaking and rms < dynamic_threshold * 2.0:
return b'\x00' * len(audio_data)
# If no significant audio, return silence
if not self.last_voice_activity:
return b'\x00' * len(audio_data)
# Noise reduction
if self.prev_noise_power is None:
self.prev_noise_power = rms**2
noise_power = self.alpha * self.prev_noise_power + (1 - self.alpha) * rms**2
self.prev_noise_power = noise_power
gain = np.maximum(1 - (noise_power / (rms**2 + 1e-10)), 0.1)
# Additional gain reduction when bot is speaking
if self.is_speaking:
gain *= 0.3 # Stronger gain reduction when bot is speaking
processed_audio = audio_float * gain
return (processed_audio * 32768).astype(np.int16).tobytes()
def pause_mic(self):
"""Pause the microphone stream to prevent capturing speaker's audio."""
self.should_send_audio = False
if self.stream.is_active():
self.stream.stop_stream()
print("[+] Microphone stream paused")
def resume_mic(self):
"""Resume the microphone stream after speaker finishes."""
if not self.stream.is_active():
self.stream.start_stream()
self.should_send_audio = True
print("[+] Microphone stream resumed")
async def handle_mpv(self):
"""Handle MPV cleanup after audio playback ends."""
if self.mpv_process and self.mpv_process.stdin:
try:
self.mpv_process.stdin.close()
except BrokenPipeError:
print("[-] Broken pipe error while writing to MPV")
await self.mpv_process.communicate()
self.mpv_process = None
# self.playback_complete.set()
self.is_speaking = False
print("[+] MPV playback completed")
message = {"type": "text", "data": "bot-speak-end"}
await self.websocket.send(json.dumps(message))
async def send_data(self):
buffer_size = 5 # Number of frames to buffer
audio_buffer = []
while True:
try:
if not self.should_send_audio:
await asyncio.sleep(0.01)
continue
audio_bytes = self.stream.read(self.frames_per_buffer)
processed_audio = self.process_audio(audio_bytes)
# Buffer the processed audio
audio_buffer.append(processed_audio)
if len(audio_buffer) < buffer_size:
continue
# Check if any frame in buffer has significant audio
has_audio = any(np.frombuffer(frame, dtype=np.int16).any() for frame in audio_buffer)
if has_audio:
# Send all buffered frames
if self.is_speaking:
print("Interrupting")
self.mpv_process.stdin.write(b"m\n")
message = {"type": "text", "data": "interrupt-event"}
await self.websocket.send(json.dumps(message))
self.mpv_process.stdin.write(b"q\n")
await self.mpv_process.stdin.drain()
self.is_speaking = False
print("[+] STOPPED MPV...")
for frame in audio_buffer:
encoded_bytes = base64.b64encode(frame).decode("utf-8")
json_data = json.dumps({"type": "audio", "data": encoded_bytes})
await self.websocket.send(json_data)
else:
print("NO AUDIO")
# Clear buffer
audio_buffer = []
except websockets.exceptions.ConnectionClosedError:
print("[!] Connection is closed")
break
except Exception as e:
print("[!] Error in send_data:", e)
self.stream = self._create_stream()
await asyncio.sleep(0.01)
async def receive_data(self):
"""Receive data from server and handle it based on speaker type."""
while True:
try:
server_response = await self.websocket.recv()
data = json.loads(server_response)
if data["type"] == "text":
await self.handle_text_message(data)
elif data["type"] == "audio":
await self.handle_audio_message(data)
elif data["type"] == "message":
await self.handle_chat_message(data["data"])
except websockets.exceptions.ConnectionClosedError:
print("[!] Connection is closed")
break
except Exception as e:
print("[!] Error in receive_data:", e)
async def handle_chat_message(self, data):
"""Process 'chat' type messages from server."""
if data.get("final",False):
print("%s: FINAL: %s"%(data.get("role").upper(), data.get("content","")))
else:
print("%s: LIVE: %s..."%(data.get("role").upper(), data.get("content","")))
async def handle_text_message(self, data):
"""Process 'text' type messages from server based on speaker type."""
if data["data"] == "audio-init":
print("[+] BOT SPEAKING EVENT IS SET...")
# self.pause_mic()
# self.playback_complete.clear()
self.is_speaking = True
self.mpv_process = await asyncio.create_subprocess_exec(
"mpv", "--no-cache", "--no-terminal", "--", "fd://0",
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
message = {"type": "text", "data": "bot-speaking"}
await self.websocket.send(json.dumps(message))
print("[+] SENT BOT SPEAKING EVENT...")
elif data["data"] == "interrupt":
print("[+] INTERRUPTION RECEIVED...")
if self.speaker_type == "mpv" and self.mpv_process and self.mpv_process.stdin:
self.mpv_process.stdin.write(b"q\n")
await self.mpv_process.stdin.drain()
self.is_speaking = False
print("[+] STOPPED MPV...")
elif data["data"] == "audio-end":
print("[+] AUDIO END RECEIVED...")
if self.speaker_type == "mpv":
asyncio.get_event_loop().create_task(self.handle_mpv())
# await self.playback_complete.wait()
await asyncio.sleep(0.5)
# self.resume_mic()
async def handle_audio_message(self, data):
"""Process 'audio' type messages from server."""
message = {"type": "ack", "data": "audio-received"}
await self.websocket.send(json.dumps(message))
audio_bytes = base64.b64decode(data["data"])
if self.speaker_type == "mpv" and self.mpv_process and self.mpv_process.stdin and self.is_speaking:
self.mpv_process.stdin.write(audio_bytes)
await self.mpv_process.stdin.drain()
async def run(self):
"""Establish a connection to the server and start sending/receiving data."""
try:
async with websockets.connect(self.server_url) as websocket:
self.websocket = websocket
await asyncio.gather(self.send_data(), self.receive_data())
except Exception as e:
print(e)
def __del__(self):
"""Cleanup resources."""
if self.stream:
self.stream.stop_stream()
self.stream.close()
if self.py_audio_obj:
self.py_audio_obj.terminate()
if __name__ == "__main__":
voice_streamer = VoiceStreamer(speaker_type=os.getenv("SPEAKER_TYPE"))
asyncio.run(voice_streamer.run())
```
```python RaspberryPi theme={"system"}
import websockets
import asyncio
import base64
import json
import subprocess
import pyaudio
import os
from dotenv import load_dotenv
from time import time
load_dotenv(dotenv_path=".env")
class VoiceStreamer:
def __init__(self):
self.api_key = os.getenv('API_KEY')
self.default_agent = os.getenv('DEFAULT_AGENT')
self.server_url = f"{os.getenv('STREAM_SERVER_URL_WS')}/websocket/voice-stream/{self.api_key}/{self.default_agent}?devkit=true"
self.frames_per_buffer = 1024
self.format = pyaudio.paInt16
self.channels = 1
self.rate = 16000
self.websocket = None
self.last_live_transcription = time()
self.py_audio_obj = pyaudio.PyAudio()
self.stream = None
self.stream = self._create_stream()
self.mpv_process = None
self.bot_speaking = True
def _create_stream(self):
if self.stream is not None:
self.stream.stop_stream()
self.stream.close()
return self.py_audio_obj.open(
format=self.format,
channels=self.channels,
rate=self.rate,
input=True,
frames_per_buffer=self.frames_per_buffer,
start=True
)
def pause_mic(self):
if self.stream.is_active():
self.stream.stop_stream()
def resume_mic(self):
if not self.stream.is_active():
self.stream.start_stream()
async def handle_mpv(self):
if self.mpv_process and self.mpv_process.stdin:
try:
self.mpv_process.stdin.close()
await self.mpv_process.wait()
except BrokenPipeError:
pass
self.mpv_process = None
self.bot_speaking = False
message = {"type": "text", "data": "bot-speak-end"}
await self.websocket.send(json.dumps(message))
async def send_data(self):
while True:
try:
audio_bytes = self.stream.read(self.frames_per_buffer, exception_on_overflow=False)
if audio_bytes and not self.bot_speaking:
encoded_bytes = base64.b64encode(audio_bytes).decode("utf-8")
await self.websocket.send(json.dumps({"type": "audio", "data": encoded_bytes}))
except Exception:
self.stream = self._create_stream()
await asyncio.sleep(0.01)
async def receive_data(self):
while True:
try:
server_response = await self.websocket.recv()
data = json.loads(server_response)
if data["type"] == "text":
await self.handle_text_message(data)
elif data["type"] == "audio":
await self.handle_audio_message(data)
elif data["type"] == "message" and data["data"].get("final",False):
print(data["data"], flush=True)
except websockets.exceptions.ConnectionClosedError:
break
except Exception:
continue
async def handle_text_message(self, data):
if data["data"] == "audio-init":
self.mpv_process = await asyncio.create_subprocess_exec(
"mpv", "--no-cache", "--no-terminal", "--", "fd://0",
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
await self.websocket.send(json.dumps({"type": "text", "data": "bot-speaking"}))
self.bot_speaking = True
elif data["data"] == "interrupt" and self.mpv_process:
self.mpv_process.stdin.write(b"q\n")
await self.mpv_process.stdin.drain()
elif data["data"] == "audio-end":
await self.handle_mpv()
async def handle_audio_message(self, data):
while not self.bot_speaking:
await asyncio.sleep(0.2)
if self.mpv_process and self.mpv_process.stdin:
self.mpv_process.stdin.write(base64.b64decode(data["data"]))
await self.mpv_process.stdin.drain()
async def run(self):
try:
async with websockets.connect(self.server_url) as websocket:
self.websocket = websocket
await asyncio.gather(self.send_data(), self.receive_data())
except Exception:
pass
if __name__ == "__main__":
voice_streamer = VoiceStreamer()
asyncio.run(voice_streamer.run())
```
# Background Abilities
Source: https://docs.openhome.com/background-abilities
Always-on Abilities that run in parallel with the main conversation — alarms, reminders, note-taking, and ambient intelligence.
Abilities can run background threads alongside the main conversation. Add a file called `background.py` to any Ability folder and it runs automatically when the user connects to a Personality, staying alive as an independent thread for the entire session. **No hotword trigger needed.**
## What this unlocks
* **Background polling** — check a file or API on a timer
* **Proactive notifications** — interrupt the conversation when something fires
* **Scheduled tasks** — monitor for time-based events like alarms
* **Ambient monitoring** — watch the live conversation for note-taking or summarization
This is additive. Existing `main.py`-only Abilities work exactly as before.
## The four Ability categories
See [Ability Types](/ability-types) for the full breakdown. Background Daemons are one of the four.
## File structure
Every Ability is built from one or two files, regardless of which category you pick in the Dashboard.
| 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. Runs in background for monitoring, logging, note-taking. **Works even when Personality is asleep.** |
| **Interactive + Daemon** | `main.py` + `background.py` | Interactive handles user requests. Daemon runs in background. They coordinate through shared file storage. |
### Example: Interactive Combined 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.
## `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. Daemons use this to monitor the live conversation. |
| `send_interrupt_signal()` | — | Yes | Stops current Personality output. Call before `speak()` or `play_audio()` from a daemon. |
### Usage
```python theme={"system"}
# 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 daemon code template
Copy this as your starting point for any background daemon. The `call()` signature has an extra `background_daemon_mode` parameter, but the `CapabilityWorker` constructor is the same as `main.py`.
```python theme={"system"}
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 YourWatcherCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
background_daemon_mode: bool = False
#{{register capability}}
async def watcher_loop(self):
self.worker.editor_logging_handler.info(
"%s: Watcher started" % time()
)
while True:
# --- your background logic here ---
self.worker.editor_logging_handler.info(
"%s: Watcher 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.watcher_loop())
```
**Ordering matters.** `self.worker` and `self.background_daemon_mode` must be set **before** calling `CapabilityWorker(self)`. The constructor reads from `self` internally — if these aren't set first, it will fail.
## Key behaviors
### Works in sleep mode
Background daemons continue running even when the Personality is in sleep mode. The daemon is an independent thread — it does not depend on the main conversation flow being active. This means your daemon can:
* Monitor and transcribe ambient audio without a wake word
* Process conversations happening around the device
* Interject when it detects something relevant (via `send_interrupt_signal()`)
* Build RAG-style user context summaries in the background
The only requirement is that the daemon's main function runs in a never-ending `while True` loop.
### Multiple watchers
You can have multiple background daemons running simultaneously. Each is its own independent thread. For example, you could run an alarm daemon, a note-taking daemon, and a conversation summarizer all at the same time.
### Full speak capability
Background daemons have the same ability to speak as interactive Abilities. A watcher can call `speak()`, `play_audio()`, `text_to_speech()`, and all other CapabilityWorker methods. Just call `send_interrupt_signal()` first to avoid audio overlap with any active conversation.
## Coordination pattern
The primary way interactive and background components communicate is through **shared persistent file storage**. Both files read and write to the same user-scoped files.
### Example: Alarm Ability
| 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 theme={"system"}
[
{
"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"
}
]
```
## Best practices
Session tasks ensure proper cleanup when the session ends. `asyncio.sleep()` can leak.
10–30 seconds is typical. For alarms, 15–30 seconds is fine.
The JSON file may not exist yet if `main.py` hasn't been triggered. Always `check_if_file_exists()` first.
`write_file()` appends, which corrupts JSON. Always delete, then write the full object.
Background daemons run silently. `editor_logging_handler` is your only window into what they're doing.
Otherwise audio overlaps, or the system tries to transcribe your daemon's output as user input.
Background daemons work even when the Personality is asleep — but only if the main function is a never-ending `while True` loop.
## Templates & resources
| Resource | Link |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| Alarm Ability (Interactive Combined) | [openhome-dev/abilities/templates/Alarm](https://github.com/openhome-dev/abilities/tree/dev/templates/Alarm) |
| Standalone Background Daemon | [openhome-dev/abilities/templates/Background](https://github.com/openhome-dev/abilities/tree/dev/templates/Background) |
| SDK Reference | [SDK Reference](/api-sdk/sdk-reference) |
| Questions / support | [#dev-help on Discord](https://discord.com/channels/1197724389630824508/1201669938126008350) |
The Alarm template is the best reference for the Interactive Combined pattern. Study both `main.py` and `background.py` to understand how they coordinate.
# What is an Ability
Source: https://docs.openhome.com/building-abilities/ability
This guide explains Abilities in OpenHome and how to manage, customize, and create them to extend Agent functionalities.
## Introduction to Abilities
Abilities are modular extensions that enhance the functionality of your OpenHome Agents. They act as plugins, enabling your Agents to perform specialized tasks, such as fetching data from the web, controlling smart devices, or executing complex commands tailored to your project's requirements.
**Building Abilities with an AI coding assistant?** These docs come in agent-friendly formats: append `.md` to any URL for plain Markdown, or fetch [llms.txt](https://docs.openhome.com/llms.txt) (page index) or [llms-full.txt](https://docs.openhome.com/llms-full.txt) (all docs in one file). Provide them to Claude, Cursor, or any assistant to generate SDK-compliant Ability code.
With Abilities, you can:
* Add **Trigger Words** to define specific phrases or commands that activate a particular Ability.
* Use the **Marketplace** to explore and install community-created Abilities.
* Customize or create your own Abilities through the **Live Editor**, by uploading pre-built code, or from the terminal with the [OpenHome CLI](/guides/getting-started/cli).
## Managing Abilities
The **Abilities Dashboard** lets you view, manage, and customize all installed or created Abilities. Here’s what you can do:
### Tabs
* **My Abilities**: View Abilities you’ve created for your Agents.
* **Published Abilities**: Explore Abilities you’ve published to the Marketplace for others to use.
* **Installed Abilities**: Manage Abilities installed from the Marketplace or created by you.
* **Add Custom Ability**: Upload a `.zip` file containing your Ability code.
* **Live Editor**: Modify your Abilities in real-time, test them, and commit changes seamlessly.
### Ability Controls
* **Enable/Disable**: Toggle an Ability on or off as needed.
* **Agent/System Ability**: Specify whether an Ability is agent-specific or system-wide.
* **Trigger Words**: Define or edit words/phrases that activate the Ability.
* **Uninstall**: Remove an Ability from your system.
## Adding a New Ability
To create and configure a new Ability, follow these steps:
### 1. Access the Abilities Dashboard
* Navigate to the left sidebar, select **Create**, and choose **Abilities**.
### 2. Fill Out Ability Information
* **Name**: Enter a unique and descriptive name for your Ability.
* **Description**: Provide a brief overview of what the Ability does.
* **Image**: Upload an image to visually represent the Ability in your dashboard and the Marketplace.
### 3. Define Ability Behavior
* **Code Upload**: Upload a `.zip` file containing the Ability's code.
* **Trigger Words**: Add words or phrases that will activate the Ability.
* **Category**: Choose `Skill`, `Agent Controlled`, `Background Daemon`, or `Local` (when available).
* **Templates**: Select from built-in templates to simplify Ability creation.
### 4. Save and Finalize
* Click **Save Ability** to add it to your collection.
* Use the **Live Editor** for further enhancements or adjustments.
## Live Editor
The **Live Editor** provides tools to fine-tune, modify, and test your Abilities in real time. Features include:
* **File Management**: Create, delete, or modify Ability files.
* **Testing Tools**: Use the **Start Live Test** button to simulate the Ability's behavior.
* **Commit Changes**: Save modifications as a new release or revert to a previous version.
* **Trigger Keywords**: Edit trigger words directly in the editor to improve activation accuracy.
Prefer to work in your own editor? The [OpenHome CLI](/guides/getting-started/cli) provides the same create, edit, test, and commit workflow from your terminal: scaffold from a template, push your code (no manual zipping or uploading), and voice-test against your Agent without opening the Dashboard.
## Using Abilities in Agents
Abilities enhance the functionality of Agents, allowing them to:
* Respond dynamically to commands using **Trigger Words**.
* Perform specific tasks, such as retrieving weather updates, controlling devices, or generating quizzes.
* Seamlessly integrate with other components of the OpenHome ecosystem.
## Ability Categories
Every Ability falls into one of four categories: **Skill**, **Agent Controlled**, **Background Daemon**, or **Local**. See [Ability Types](/ability-types) for the full breakdown of when to use each, and [Background Abilities](/building-abilities/background-abilities) for the `background.py` pattern.
### Example Workflow
1. **Trigger Words**: A user speaks or types a command containing a pre-defined trigger word.
2. **Ability Activation**: The Agent processes the input and activates the corresponding Ability.
3. **Task Execution**: The Ability performs the task and returns the response.
4. **Dynamic Feedback**: The Agent adapts to the user's input and updates its interaction history.
## Abilities in the Marketplace
The **Marketplace** allows you to browse, install, and share Abilities created by the community.
### Features
* **Browse Abilities**: Discover new Abilities with user reviews and ratings.
* **Install/Uninstall**: Add or remove Abilities with a single click.
* **Search and Filters**: Find specific Abilities using keywords or filter by categories.
* **Featured Abilities**: Explore highlighted or trending Abilities to inspire new projects.
## Customization and Advanced Features
### Trigger Words
* Add, edit, or remove trigger words directly from the Ability settings or the Live Editor.
* Use to manage triggers effectively.
### Templates
* Built-in templates simplify the creation process.
* Customize templates to suit specific use cases or modify existing ones for advanced functionality.
## Conclusion
Abilities are the cornerstone of extending and enhancing OpenHome’s capabilities. Whether you’re building an IoT device controller, a productivity assistant, or a quiz generator, Abilities provide the flexibility to customize and scale your Agents to meet your project's unique needs. Leverage the Live Editor, Marketplace, and built-in templates to create innovative solutions and contribute to the growing OpenHome ecosystem.
> Start building and transforming your ideas into reality with OpenHome Abilities! 🎉
# Agent Controlled Abilities
Source: https://docs.openhome.com/building-abilities/agent-controlled-abilities
Abilities the Agent can trigger autonomously on the user's behalf when a request needs them, such as fetching real-time information or taking an action.
An **Agent Controlled** Ability is one the Agent can trigger **autonomously**. Rather than relying on the user to invoke it, the Agent reads the user's request and, when an Agent Controlled Ability is the right fit, triggers it on the user's behalf and presents the result as part of its own response.
For example, answering *"is it raining outside?"* needs real-time information the Agent does not have on its own. With a Weather Ability set to **Agent Controlled**, the Agent recognizes that the request needs live data, triggers the Ability, and answers the user with the current conditions.
This makes the Agent feel like a smart assistant that knows *when* to reach for the right tool. The user simply speaks naturally, and the Agent decides what is needed to answer well. The [Live Web Search](https://github.com/openhome-dev/abilities/blob/dev/official/perplexity-web-search/main.py) Ability is a real example. The user asks something that needs current information, and the Agent autonomously runs a web search to answer it.
Agent Controlled Abilities require trigger words. The Agent uses them to recognize and trigger the Ability when the user's request calls for it.
## The four Ability categories
Every Ability falls into one of four categories: **Skill**, **Agent Controlled**, **Background Daemon**, or **Local**. See [Ability Types](/ability-types) for the full breakdown of when to use each.
## How it works
The Agent triggers an Agent Controlled Ability **autonomously**. It reads the user's request and, when the Ability is the right fit, triggers it from that request, so the user does not have to invoke it themselves. The Agent triggers an Ability only when the user's request clearly calls for it. Casual conversation, greetings, and acknowledgements do not trigger it.
Once triggered, the Ability runs its normal flow using the same `CapabilityWorker` methods as any other Ability. While it runs, the Agent acts between the Ability and the user:
* **If the Ability needs more information** to do its job, such as which city to check the weather for, the Agent answers the Ability's question. It provides the answer from the user context it already has, and if that context does not contain the answer, it asks the user. Either way, the conversation keeps flowing, and the user can keep talking to the Agent while the Ability works.
* **When the Ability produces a result**, the Agent collects it and presents it to the user in its own words, shaped to answer what the user actually asked.
Because the Agent voices the result, your Ability's spoken output is not delivered word for word. Write it as clear, factual content and let the Agent phrase the final response.
Agent Controlled Abilities are always mediated by the Agent. Even when the Ability is triggered by a trigger word, the Agent controls how and when the result reaches the user and weaves it into the conversation, rather than the Ability replying to the user directly. If you want an Ability that responds to the user directly and immediately when it is invoked, use a [Skill](/ability-types) category Ability instead.
### Example interaction
A user asks the Agent a question naturally, and the Agent answers using an Agent Controlled Weather Ability.
> **User:** Is it snowing outside?
>
> **Agent:** In San Francisco, it's currently a light drizzle, not snowing. It's about 14 degrees, though it feels closer to 12.
The user never named a city or invoked the Ability. The Agent recognized that the request needed live weather, triggered the Ability, filled in the city it already knew, and answered the actual question of whether it was snowing, all in its own words.
## When to use Agent Controlled
The clearest signal is **how the user phrases the request**. When a user asks a question or states an intent indirectly rather than issuing a direct command, the Agent should recognize the need and act on it. That is what Agent Controlled is for.
| The user says… | Best fit | Why |
| -------------------------- | -------------------- | ------------------------------------------------------------------------------------ |
| *"is it raining outside?"* | **Agent Controlled** | An indirect request. The Agent recognizes the need and triggers the Weather Ability. |
| *"open weather"* | **Skill** | A direct command. The user is explicitly invoking the Ability. |
Choose **Agent Controlled** when:
* The Ability provides something the Agent cannot produce on its own, such as real-time information, data from the user's connected accounts, or an action in the outside world.
* You want the Agent to decide when the Ability is needed, based on what the user is asking.
* The result should be woven into the Agent's conversational response rather than spoken by the Ability directly.
### Good candidates
| Category | Examples |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------- |
| **Real-time information** the Agent lacks on its own | Weather, web search, news, sports scores, stock or crypto prices |
| **Personal data** from connected accounts | Calendar lookups, email, tasks, reminders |
| **Actions and external services** | Sending a message, adding a calendar event, updating a list, querying an external API |
### When not to use Agent Controlled
Some Abilities are a better fit as a [Skill](/ability-types). Use a Skill instead when:
* **The user invokes it deliberately with a command.** For example, *"start my morning routine"* or *"enter focus mode."* The user wants explicit control rather than autonomous triggering.
* **The Ability runs a long, interactive session.** Examples include a guided flow, a quiz, or a multi-step walkthrough that the user commits to, rather than a single question the Agent answers.
* **The Agent can already answer from its own knowledge.** General questions that do not need live data or an external action fall here. Routing these through an Ability only adds latency.
* **You want an immediate, direct reply when the Ability is triggered.** Agent Controlled results are delivered by the Agent as part of the conversation, not as an instant reply from the Ability itself. For a direct, interactive flow that the user triggers with a trigger word, use a Skill.
## File structure
An Agent Controlled Ability is built from the same files as a Skill. At minimum it is a single `main.py` that contains the Ability class and its `run()` logic.
## Building an Agent Controlled Ability
You write an Agent Controlled Ability **exactly like a Skill**, using the same SDK methods, the same `main.py` structure, and the same lifecycle. There is no special base class or special code. What makes an Ability Agent Controlled is its **category** and its **description**.
### The description gives the Agent context
The Agent reads your Ability's **description** to understand what the Ability does and extracts the context it needs to decide when the Ability is relevant to a user's request. Write a clear, specific description of the Ability's purpose and the kind of requests it handles. A vague description gives the Agent too little context, so it may trigger the Ability at the wrong time, or not at all.
A strong description states the purpose, the scope, and a few example requests.
```text theme={"system"}
Gives real-time weather and current conditions for any city, so users can
just ask about the weather naturally. Anything that needs current weather
for a location can be handled by this ability.
Examples:
- what's the weather in Miami
- is it raining in Tokyo
- how hot is it outside
```
### Example
This Weather Ability reports the current conditions for a city using the free [Open-Meteo](https://open-meteo.com/) API, which requires no API key. It is written like any Skill, and the **category** is what makes it Agent Controlled.
When the Ability asks *"which city?"*, the Agent provides the answer. It uses the city it already knows for the user when it can, or it asks the user. The Ability code is the same either way.
```python theme={"system"}
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
WEATHER_URL = "https://api.open-meteo.com/v1/forecast"
WEATHER_CODES = {
0: "clear skies", 1: "mainly clear", 2: "partly cloudy", 3: "overcast",
45: "foggy", 51: "light drizzle", 61: "light rain", 63: "rain", 65: "heavy rain",
71: "light snow", 80: "rain showers", 95: "thunderstorms",
}
class WeatherCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
#{{register capability}}
async def run(self):
try:
# Ask for the detail the Ability needs. When the Agent triggers this
# Ability, the Agent supplies the answer, either from what it already
# knows about the user or by asking them. The Ability code is the same.
city = await self.capability_worker.run_io_loop(
"Which city would you like the weather for?"
)
if not city or not city.strip():
await self.capability_worker.speak("I didn't catch a city name.")
return
city = city.strip()
geo = await self.worker.session_tasks.httpx_get_async(GEOCODE_URL, params={
"name": city, "count": 1, "language": "en", "format": "json"
})
results = geo.json().get("results") or []
if not results:
await self.capability_worker.speak(f"I couldn't find {city}.")
return
place = results[0]
forecast = await self.worker.session_tasks.httpx_get_async(WEATHER_URL, params={
"latitude": place["latitude"],
"longitude": place["longitude"],
"current": "temperature_2m,weather_code",
})
current = forecast.json().get("current") or {}
temp = current.get("temperature_2m")
condition = WEATHER_CODES.get(current.get("weather_code"), "variable conditions")
# Return factual content. The Agent voices the result in its own words,
# shaped to answer what the user actually asked.
summary = f"In {place['name']}, it's currently {condition} at {round(temp)} degrees Celsius."
await self.capability_worker.speak(summary)
except Exception as error:
self.worker.editor_logging_handler.error(f"weather error: {error}")
await self.capability_worker.speak("Something went wrong while checking the weather.")
finally:
self.capability_worker.resume_normal_flow()
def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self.worker)
self.worker.session_tasks.create(self.run())
```
The Ability uses standard `CapabilityWorker` methods. For the full method catalog, see [Building Abilities](/building-abilities/how-to-build) and the [SDK Reference](/api-sdk/sdk-reference).
## Creating an Agent Controlled Ability
1. Open the [Dashboard](https://app.openhome.com/dashboard/home) and create a new Ability.
2. Fill in the Ability information. Write a **clear, specific description**. The Agent reads it to understand what the Ability does and to decide when to trigger it.
3. Under **Ability Behavior**, set the **Category** to **Agent Controlled**.
4. Add **Trigger Words**. These are **required**, and the Agent uses them to recognize and trigger the Ability.
## Testing your Ability
Because the Agent decides when to trigger an Agent Controlled Ability, test it by speaking naturally rather than by saying an exact command.
1. Phrase a request that matches your Ability's description.
2. Confirm that the Ability is triggered, performs its action, and the Agent presents the result.
3. If the Ability does not trigger, refine the description so it more clearly states the requests it handles. A clearer, more specific description helps the Agent recognize when the Ability is relevant.
## Best practices
The Agent reads the description to understand what your Ability does and extracts the context it needs to decide when it's relevant. State the purpose, the scope, and a few example requests so the Agent triggers it at the right time. Keep descriptions distinct, because overlapping descriptions across Abilities make it harder for the Agent to choose.
Trigger words are required for Agent Controlled Abilities. The Agent uses them to recognize and trigger the Ability, so choose clear, distinct words or phrases.
The Agent presents your Ability's result in its own words, tailored to what the user asked, so your text is not spoken verbatim. Return clear, factual content and avoid fixed phrasing such as "Here's what I found."
Agent Controlled works best for a focused task that answers a request, ideally one that needs little or no extra input. The fewer details the Ability has to gather, the smoother the experience.
Call `resume_normal_flow()` on every exit path, including success, failure, and early returns, to return control to the Agent.
## See also
* [Ability Types](/ability-types) — when Agent Controlled is the right choice compared with Skill, Background Daemon, or Local
* [Building Abilities](/building-abilities/how-to-build) — the full Ability authoring guide and SDK methods
* [SDK Reference](/api-sdk/sdk-reference) — the complete method catalog
* [Background Abilities](/building-abilities/background-abilities) — always-on Abilities that run alongside the conversation
Agent Controlled Abilities are still in active development and are being finalized. Their behavior and documentation may change as the category matures.
# Background Abilities
Source: https://docs.openhome.com/building-abilities/background-abilities
Always-on Abilities that run in parallel with the main conversation — alarms, reminders, note-taking, and ambient intelligence.
Abilities can run background threads alongside the main conversation. Add a file called `background.py` to any Ability folder and it runs automatically when the user connects to a Personality, staying alive as an independent thread for the entire session. **No hotword trigger needed.**
## What this unlocks
* **Background polling** — check a file or API on a timer
* **Proactive notifications** — interrupt the conversation when something fires
* **Scheduled tasks** — monitor for time-based events like alarms
* **Ambient monitoring** — watch the live conversation for note-taking or summarization
This is additive. Existing `main.py`-only Abilities work exactly as before.
## The four Ability categories
See [Ability Types](/ability-types) for the full breakdown. Background Daemons are one of the four.
## File structure
Every Ability is built from one or two files, regardless of which category you pick in the Dashboard.
| 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. Runs in background for monitoring, logging, note-taking. **Works even when Personality is asleep.** |
| **Interactive + Daemon** | `main.py` + `background.py` | Interactive handles user requests. Daemon runs in background. They coordinate through shared file storage. |
### Example: Interactive Combined 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.
## `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. Daemons use this to monitor the live conversation. |
| `send_interrupt_signal()` | — | Yes | Stops current Personality output. Call before `speak()` or `play_audio()` from a daemon. |
### Usage
```python theme={"system"}
# 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 daemon code template
Copy this as your starting point for any background daemon. The `call()` signature has an extra `background_daemon_mode` parameter, but the `CapabilityWorker` constructor is the same as `main.py`.
```python theme={"system"}
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 YourWatcherCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
background_daemon_mode: bool = False
#{{register capability}}
async def watcher_loop(self):
self.worker.editor_logging_handler.info(
"%s: Watcher started" % time()
)
while True:
# --- your background logic here ---
self.worker.editor_logging_handler.info(
"%s: Watcher 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.watcher_loop())
```
**Ordering matters.** `self.worker` and `self.background_daemon_mode` must be set **before** calling `CapabilityWorker(self)`. The constructor reads from `self` internally — if these aren't set first, it will fail.
## Key behaviors
### Works in sleep mode
Background daemons continue running even when the Personality is in sleep mode. The daemon is an independent thread — it does not depend on the main conversation flow being active. This means your daemon can:
* Monitor and transcribe ambient audio without a wake word
* Process conversations happening around the device
* Interject when it detects something relevant (via `send_interrupt_signal()`)
* Build RAG-style user context summaries in the background
The only requirement is that the daemon's main function runs in a never-ending `while True` loop.
### Multiple watchers
You can have multiple background daemons running simultaneously. Each is its own independent thread. For example, you could run an alarm daemon, a note-taking daemon, and a conversation summarizer all at the same time.
### Full speak capability
Background daemons have the same ability to speak as interactive Abilities. A watcher can call `speak()`, `play_audio()`, `text_to_speech()`, and all other CapabilityWorker methods. Just call `send_interrupt_signal()` first to avoid audio overlap with any active conversation.
## Coordination pattern
The primary way interactive and background components communicate is through **shared persistent file storage**. Both files read and write to the same user-scoped files.
### Example: Alarm Ability
| 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 theme={"system"}
[
{
"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"
}
]
```
## Best practices
Session tasks ensure proper cleanup when the session ends. `asyncio.sleep()` can leak.
10–30 seconds is typical. For alarms, 15–30 seconds is fine.
The JSON file may not exist yet if `main.py` hasn't been triggered. Always `check_if_file_exists()` first.
`write_file()` appends, which corrupts JSON. Always delete, then write the full object.
Background daemons run silently. `editor_logging_handler` is your only window into what they're doing.
Otherwise audio overlaps, or the system tries to transcribe your daemon's output as user input.
Background daemons work even when the Personality is asleep — but only if the main function is a never-ending `while True` loop.
## Templates & resources
| Resource | Link |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| Alarm Ability (Interactive Combined) | [openhome-dev/abilities/templates/Alarm](https://github.com/openhome-dev/abilities/tree/dev/templates/Alarm) |
| Standalone Background Daemon | [openhome-dev/abilities/templates/Background](https://github.com/openhome-dev/abilities/tree/dev/templates/Background) |
| SDK Reference | [SDK Reference](/api-sdk/sdk-reference) |
| Questions / support | [#dev-help on Discord](https://discord.com/channels/1197724389630824508/1201669938126008350) |
The Alarm template is the best reference for the Interactive Combined pattern. Study both `main.py` and `background.py` to understand how they coordinate.
# Simple Abilities Cookbook
Source: https://docs.openhome.com/building-abilities/cookbook
200+ Ability ideas organized by location, user, and use case — copy, remix, ship.
The best Ability does something the LLM can't, at a moment the user didn't expect, with information accumulated over time, delivered in fewer words than expected.
This catalog is organized so you can find relevant ideas fast — **by room, by user, by use case, or by vibe.** Every entry is a seed. Pick one, pass it to an LLM with the [SDK Reference](/api-sdk/sdk-reference), and scaffold.
**Working with an LLM?** Copy any section below and paste it into Claude, Cursor, or the [OpenHome CLI](/guides/getting-started/cli) alongside the SDK Reference. The LLM has everything it needs to draft a working Ability.
***
## By location
### Nightstand / Bedroom
| Ability | Description |
| -------------------------- | ------------------------------------------------------------------------------ |
| Morning Manifest | One sentence: the one thing on your calendar that matters most today |
| Lights Out Debrief | Voice-dump everything on your mind; organized into actionable items by morning |
| Tomorrow's Weather Whisper | Only speaks if weather demands action: *"You'll want a coat"* |
| Bedtime Story Engine | Serialized adventure for kids, remembers where it left off |
| Midnight Worry Jar | Captures anxious 2 AM thoughts, reframes as calm to-dos by breakfast |
| Gratitude Fade-Out | One good thing from today, ambient tone, silence. 15-second ritual. |
| Morning Body Check | *"How are you feeling?"* every morning. One word. Patterns after a month. |
| Dream Catcher | Transcribes sleep-talking, builds dream journal with recurring themes |
| Sleep Debt Tracker | Logs bed/wake times, weekly report on target gap |
| Power Nap Coach | Optimal wake point, transition sounds, prevents deep sleep |
### Living Room (Couple)
| Ability | Description |
| --------------------- | -------------------------------------------------------------------------------------- |
| Settle It | Game-show ruling with sound effects for trivial disagreements |
| Movie Matchmaker | Each secretly states mood, speaker threads the needle without revealing preferences |
| Dinner Decider | One confident suggestion based on recent meals, preferences, and season |
| Couple's Trivia | Pub quiz for two, running all-time scoreboard across weeks |
| The Argument Cooldown | Detects heated voices, waits for pause, interjects with something disarming |
| Weekend Planner | Friday evening, single activity pitch based on weather + interests + local events |
| Guest Mode | Unfamiliar voices → suppresses personal notifications, switches to party behaviors |
| Anniversary Vault | Silently captures inside jokes, laughter, heartfelt moments. Compiles for anniversary. |
| Background Narrator | Narrates mundane activities in nature-documentary or sports-broadcast style |
### Kitchen
| Ability | Description |
| -------------------------- | ------------------------------------------------------------------- |
| Recipe Walkthrough | Hands-free, pace-adaptive step-by-step with *"next"* and *"repeat"* |
| Grocery List Builder | Overhears *"we're out of milk"* and silently adds to list |
| Cooking Timer Orchestrator | Multiple named timers running simultaneously |
| Kitchen Radio DJ | Plays music + gives brief news/weather during natural breaks |
| Sous Chef Advisor | *"Can I substitute X for Y?"* Quick one-shot answers |
### Conference Room
| Ability | Description |
| -------------------------- | ------------------------------------------------------------------ |
| Decision Logger | Extracts only decisions from discussion noise into a clean list |
| Action Item Extractor | Detects task assignments: owner + task + deadline |
| Meeting Recap | 5-sentence executive summary within 60 seconds of meeting end |
| Who Talked Most | Speaking time per person via diarization |
| Pre-Meeting Briefer | 15-second recap of last meeting's decisions and outstanding items |
| Follow-Up Drafter | Drafts post-meeting email with summary + action items + next steps |
| Agenda Enforcer | Gentle chime when group drifts off-topic or overspends time |
| Cross-Meeting Intelligence | Connects dots across different meetings in the same week |
### College Dorm
| Ability | Description |
| ------------------------ | ------------------------------------------------------------------------------ |
| Study Pomodoro Coach | 25 min focus, break with fun fact, weekly study-hours log |
| Exam Countdown | Daily casual drop: how many days until next exam |
| Cram Session Quiz Master | Infinite rapid-fire quiz adapting to weak spots |
| Budget Buddy | Logs every spending mention, Sunday weekly total with categories |
| Wake Up Enforcer | Adapts aggression to class importance. Reads assignments if you keep snoozing. |
| Roommate Mediator | Tracks shared responsibilities, produces fairness log |
### Home Office
| Ability | Description |
| -------------------- | ------------------------------------------------------------------- |
| Focus Guardian | Blocks interruptions during deep work, only surfaces urgent items |
| Standup Generator | Summarizes what you worked on yesterday from ambient context |
| Meeting Prep Briefer | Before each call, reads attendee context and last-interaction notes |
| End-of-Day Wrap | *"Here's what you did today"* summary from overheard context |
### Car / Commute
| Ability | Description |
| -------------------- | ---------------------------------------------------------------- |
| Commute Debrief | Processes the day on the drive home, captures what went well |
| Hands-Free Messenger | *"Tell Sarah I'm 10 minutes out"* with zero screen interaction |
| Traffic-Aware ETA | Proactively updates ETA as conditions change without being asked |
| Errand Optimizer | Knows your to-do list + route, suggests optimal stop order |
***
## By user
### Kids (ages 5–12)
| Ability | Description |
| ------------------ | -------------------------------------------------------------------------- |
| Homework Helper | Walks through problems step by step without giving the answer |
| Would You Rather | Endless escalating scenarios, remembers which got biggest laughs |
| Animal Expert | Any animal → 3 mind-blowing facts → *"want another or pick a new animal?"* |
| Story Builder | Collaborative choose-your-own-adventure with AI as wildcard narrator |
| Spelling Bee Coach | Gives word, uses in sentence, tracks mastery across sessions |
| Mystery Detective | Short mystery scene, kid asks yes/no questions to solve the case |
### Kids games (ages 8–10)
| Ability | Description |
| ------------------ | ------------------------------------------------------------------------------ |
| Boss Battle Trivia | Correct answers deal damage to bosses. Wrong = boss attacks. Loot drops. |
| Monster Collector | Correct answers catch randomly generated monsters. Common to legendary rarity. |
| Speed Round | 3-second timer, dramatic streak counter, sports-commentator scoring |
| Dungeon Crawler | Persistent character, levels up across days, permadeath = real stakes |
| Conspiracy Board | One new clue per day. Solve the week-long mystery. |
### Parents
| Ability | Description |
| -------------------------- | -------------------------------------------------------------------- |
| Baby Sleep Tracker | Logs sleep/wake cycles from ambient audio, surfaces patterns |
| Toddler Vocabulary Tracker | Maps language development against milestones, flags potential delays |
| Family Calendar Sync | *"Does anyone have anything Thursday?"* checks all family calendars |
| Bedtime Routine Manager | Guides kids through brush teeth → story → lights out sequence |
### Elderly users
| Ability | Description |
| ------------------------ | ------------------------------------------------------------------------------ |
| Medication Reminder | Gentle, persistent, logs whether confirmation was given |
| Cognitive Wellness Check | Tracks word-finding difficulty and repetition over months |
| Family Connection | *"Call your daughter"* with simplified voice dialing |
| Daily Companion | Morning greeting, weather, news headlines, gentle check-in. Combats isolation. |
### Professionals
| Ability | Description |
| ---------------------- | ---------------------------------------------------------------------- |
| Executive Brief | Morning synthesis of calendar, market moves, key emails, team updates |
| Sales Call Scorer | Post-call analysis of talk ratio, question quality, objection handling |
| Client Meeting Debrief | After client leaves: *"What did we learn? What do we owe them?"* |
***
## By use case
### Health & wellness
| Ability | Description |
| -------------------------- | -------------------------------------------------------------------------------- |
| Mood Logger | Daily one-word check-in. Monthly patterns. Seasonal insights. |
| Guided Meditation Selector | Picks meditation from API based on time, mood, stress level |
| Breathing Exercise Coach | Guided box breathing, 4-7-8, Wim Hof with voice timing |
| Symptom Tracker | Logs mentions of how you feel. *"You've mentioned headaches 4 times this week."* |
| Voice Health Scanner | Detects micro-changes in pitch, pace, breathiness → early-illness detection |
### Productivity
| Ability | Description |
| ------------------------------ | ---------------------------------------------------------------------------- |
| Inbox Zero Coach | Reads email subjects, you triage by voice: *"delete, reply later, urgent"* |
| Voice-to-Task | *"Remind me to call the plumber Thursday"* → creates task in Todoist/Asana |
| Weekly Review | Friday afternoon: what you accomplished, what carried over, what's next week |
| Voice Notes to Structured Docs | Rambling voice input → organized markdown/PDF output |
### Finance
| Ability | Description |
| -------------------------- | ------------------------------------------------------------------ |
| Portfolio Pulse | Morning one-liner: how your investments moved overnight |
| Spending Tracker | Logs every mentioned purchase. Sunday summary with categories. |
| Trending Stocks | What retail traders are buzzing about. Top movers. Unusual volume. |
| Bank Balance Reality Check | *"Can I afford that?"* → pulls actual balance + upcoming bills |
### Entertainment
| Ability | Description |
| ---------------------- | --------------------------------------------------------------------------------- |
| Song of the Day | Summarizes mood + events, sends to Suno API, generates unique song about YOUR day |
| Movie/Show Recommender | Learns taste over weeks, factors in mood, who's in the room, time of day |
| Live Sports Companion | Score updates, key plays, proactive alerts when it gets close |
| Spotify Time Machine | *"What was I listening to a year ago today?"* → nostalgia playlist |
### Shopping & logistics
| Ability | Description |
| ------------------- | --------------------------------------------------------------------------- |
| Price Watcher | *"Watch that TV on Amazon"* → monitors → announces when price drops |
| Grocery Auto-Order | List builds passively from kitchen mentions → sends order for confirmation |
| Package Tracker | *"Where's my stuff?"* → consolidates all deliveries, proactive delay alerts |
| Gift Idea Collector | Logs when family mentions wanting something. Surfaces list before holidays. |
### Smart home & IoT
| Ability | Description |
| ---------------- | -------------------------------------------------------------------------- |
| Scene Controller | *"Movie time"* → dims lights, sets thermostat, closes blinds, starts media |
| Morning Routine | *"Good morning"* triggers lights, coffee, weather, calendar in sequence |
| Security Check | *"Is the house locked up?"* → checks all locks, cameras, alarm status |
***
## Always-on and Watcher
Abilities that run as [Background Daemons](/building-abilities/background-abilities) — no wake word, continuous monitoring.
| Ability | Description |
| -------------------- | --------------------------------------------------------------- |
| Life Logger | Always-on ambient capture, daily summaries to dashboard |
| Baby Monitor Plus | Detects crying, unusual silence, sleep breathing. Alerts phone. |
| Meeting Scribe | Auto-starts when 3+ voices heard, writes notes until silence |
| Daily To-Do Compiler | Catches all *"I need to..."* mentions → to-do list by evening |
| Gratitude Harvester | Catches positive statements all day, weekly gratitude list |
| Dream Recorder | Captures sleep-talking, builds journal without lifting a finger |
| Profanity Jar | Beeps on bad words. Running tally. Weekly fine announcement. |
***
## Hot-mic and Deepgram showcase
Abilities that use [raw mic access + Deepgram](/building-abilities/hot-mic-deepgram):
| Ability | Description |
| ---------------------------- | ---------------------------------------------------------------------------------- |
| Meeting Notes | Records the meeting, diarizes via Deepgram, returns speaker-labeled notes |
| Interview Recorder | Records full interview. Q\&A transcript, key quotes, candidate summary |
| Argument Referee | Neutral diarized summary: *"Speaker A's main point was X. Speaker B's was Y."* |
| Language Practice Partner | Records you in target language, flags low-confidence words for pronunciation |
| Public Speaking Coach | Pace, filler words, sentence variation → coaching notes |
| Dog Bark Detector | Classifies barking frequency and timing. *"Buddy barked 12 times, mostly 2–3 PM."* |
| Doorbell Detector | Percussive audio events. No smart doorbell needed. |
| Appliance Sound Monitor | Listens for buzzers — laundry, oven, dishwasher done signals |
| Glass Break / Alarm Detector | High-energy audio events, optionally sends WebSocket alert |
| Song ID | Records short ambient clip → music recognition API → title + artist |
| Noise Level Monitor | Analyzes raw PCM amplitude. No API needed. Reports spikes. |
***
## Creative & maker
| Ability | Description |
| ------------------- | -------------------------------------------------------------- |
| Beat Maker | Describe a vibe, AI generates beat via audio API for freestyle |
| Sound Effect Studio | *"Spaceship landing sound"* → ElevenLabs SFX API generates it |
| Writing Prompt | Daily creative prompt tailored to genre and current project |
| Remix My Day | Transcript of your day → generates lo-fi ambient track from it |
| Mood Playlist | Detects mood from voice, generates Spotify playlist to match |
***
## Niche & weird
| Ability | Description |
| ------------------ | --------------------------------------------------------------- |
| Wine Pairing | Describe dinner, AI suggests wine from sommelier API |
| Dad Joke Engine | Endless supply, progressively worse, groan-tracking scoreboard |
| Plant Care | *"How often water my fiddle leaf fig?"* + scheduled reminders |
| Hot Take Generator | Spicy opinion generated, group debates, AI judges |
| Life Narrator | Morgan Freeman mode: narrates what you're doing in real-time |
| Compliment Machine | Daily compliment on detection. Silly but surprisingly powerful. |
| Random Fact Cannon | Fires obscure fact at random meal times. Greatest-hits list. |
***
## How to pick one
1. **Scan by your target location or user** — which table fits?
2. **Pick one that makes you smile or solves a real pain**
3. **Open [How to Build an Ability](/building-abilities/how-to-build)**
4. **Paste the row into an LLM** along with the [SDK Reference](/api-sdk/sdk-reference) and ask for a scaffold
5. **Ship to the [Marketplace](/marketplace)** when it works
> The best Ability does something the LLM can't, at a moment the user didn't expect, with information accumulated over time, delivered in fewer words than expected.
# Designing OpenHome Abilities
Source: https://docs.openhome.com/building-abilities/designing-abilities
A manifesto for voice-first ambient intelligence: philosophy, frameworks, sound design, lifecycle, API integrations, OpenClaw bridge, and ability ideation.
# Designing OpenHome Abilities
> A manifesto for voice-first ambient intelligence.
Philosophy - Frameworks - Sound Design - Lifecycle - API Integrations - OpenClaw Bridge - 170+ Ability Ideas
This is the long-form single-page manifesto. The same material is also organized into focused pages for easier reference:
* Voice-first rules & sound design → [Voice-First Best Practices](/guides/best-practices/voice-first)
* 170+ Ability ideas → [Simple Abilities Cookbook](/building-abilities/cookbook)
* The four Ability categories → [Ability Types](/ability-types)
* Implementation patterns, SDK usage, code examples → [What Makes a Good Ability](/building-abilities/what-makes-a-good-ability)
* OpenClaw integration → [Connect to OpenClaw](/building-abilities/openclaw)
## Part 1: Philosophy
### 1.1 - The Core Premise
* You are not building an app. You are building a presence in a room.
* A smart speaker is a microphone, a speaker, and a brain that never sleeps.
* The best Ability is the one the user forgets is running - until it does something so well-timed they think: "How did it know?"
**Key insight:** It knew because it was there. Listening. Learning. Waiting.
### 1.2 - The Three Modes of Operation
| Mode | What It Does | Key Principle |
| --------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Listening | Captures ambient audio, transcribes speech, identifies speakers, detects sounds, extracts meaning | The user may not even be talking to the device |
| Speaking | Interjects, responds, narrates, coaches, entertains | Voice is expensive. Every word is a second the user cannot skip. Silence is often better. |
| Logging | Writes to persistent backends, companion apps, dashboards silently | Accumulates intelligence over hours, days, and weeks. The most powerful layer. |
### 1.3 - When Something Should Be an Ability
If the LLM can handle it with a Agent prompt alone, it does not need to be an Ability.
Abilities exist for things the LLM cannot do on its own:
* Call an external API
* Play or generate audio
* Control a physical device
* Persist data across sessions
* Run multi-step workflows with branching logic
* Access real-time data (weather, scores, stocks, calendars)
**Pro tip:** Ask yourself: "Does this require reaching outside the LLM?" If yes, it is an Ability.
## Part 2: Design Frameworks
### 2.1 - The Three Ability Archetypes
| Archetype | Behavior | Examples |
| ------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| The Responder | Mostly silent. User initiates. Speaker answers, then exits. | Weather, timer, WiFi password, quick lookup |
| The Companion | Active participant in ongoing back-and-forth. Has agent and memory. | Debate coach, recipe walkthrough, brainstorm partner, bedtime story |
| The Observer | Mostly silent. Listens, transcribes, analyzes, logs, surfaces insights later. | Life logger, meeting transcriber, sleep tracker, dream decoder |
**Pro tip:** The Observer archetype is underused and extremely powerful. Silence is the feature.
### 2.2 - Ten Design Frameworks
1. **The Invisible Worker** - Handles tedious labor users would never manually maintain.
2. **The Information Funnel** - Compresses many dashboards and apps into one timely spoken sentence.
3. **Surprise Artifact Generation** - Builds over time and delivers meaningful outputs unexpectedly.
4. **The Emotional Radar** - Adapts behavior based on how users sound, not only what they say.
5. **The Daily Ritual Anchor** - Attaches to existing habits, not net-new behaviors.
6. **The Compound Intelligence Loop** - Gets smarter over weeks; value compounds over time.
7. **The Proxy Agent** - Acts for the user (send, book, reorder), not only informs.
8. **The Social Multiplier** - Designs for rooms with multiple people.
9. **The Context Mesh** - Weaves multiple sources into contextual intelligence.
10. **The Graceful Silence Principle** - Define silence rules first; speak less than possible.
## Part 3: Voice-First Design Rules
### 3.1 - Keep It Short
* Keep each `speak()` to 1-2 sentences.
* Lead with the headline.
* Use progressive disclosure.
Example: "You have 3 meetings. Next is at 2 with Sarah. Want the full list?"
### 3.2 - Fill the Silence
* If an API call takes over 1 second, speak first.
* Example fillers: "One sec, pulling that up." "Hang on, checking." "Let me look into that."
* Dead silence feels broken.
### 3.3 - Confirm Before Acting
* Destructive or high-stakes actions need confirmation.
* Example: "Cancel Team Standup? Say yes to confirm."
* Low-stakes lookups can run directly.
### 3.4 - Expect Messy Input
* Transcription is messy.
* Use the LLM to extract clean intent.
* If parsing fails, ask again explicitly.
### 3.5 - Handle Exits
* Looping abilities need exit words: `done`, `stop`, `bye`, `nothing else`, `I'm good`.
* One idle cycle: keep going.
* Two idle cycles: offer to leave.
* Call `resume_normal_flow()` on every path.
### 3.6 - Spell It Out
* TTS can mangle emails, URLs, and numbers.
* Say "at" for `@`, "dot" for `.`.
* Read phone numbers digit by digit.
### 3.7 - Silence Is a Feature
* Do not respond to every moment.
* Log interesting details silently.
* Do not read more than three items without asking.
## Part 3B: Sound Design - Audio as Interface
Voice abilities are audio experiences, not only speech.
### Sound Effect Types
| Type | When to Use | Example |
| ------------------ | ------------------------------------------ | ---------------------------------------- |
| Confirmation Tones | Action completes successfully (low stakes) | "Lights off" -> soft click |
| Transition Sounds | Switching modes or states | Entering ability -> short whoosh |
| Intro Music/Themes | Companion or game abilities | Trivia -> game show sting |
| Feedback Beeps | Correct/wrong, milestones, timers | Correct -> bright pip, wrong -> low tone |
| Ambient Audio | Atmosphere under speech | Focus mode -> low lo-fi; sleep -> rain |
| Alert/Interrupt | Background interruptions | Timer done -> escalating soft alarm |
### Sound Design Principles
* Less is more.
* Consistency builds trust.
* Time-of-day awareness is mandatory.
* Let sounds replace words over repeated usage.
**Key insight:** Over time, sound can replace spoken confirmations as users learn the audio language.
### Sound Anti-Patterns
* Sound effects on every `speak()`.
* Long intros that delay useful speech.
* Loud nighttime alerts.
* Alarm-like sounds that induce panic.
* Loops that clash with speech.
* Inconsistent sounds for the same action.
## Part 4: Trigger Word Design
### 4.1 - Think in Speech, Not Text
Users say: "what's on my calendar", "do I have a 3pm", "am I free Tuesday".
### 4.2 - Balance Coverage vs False Positives
| Trigger Type | Risk | Strategy |
| ------------------------------------------------- | ---------- | ------------------------- |
| Safe single words (`calendar`, `weather`) | Low | Use freely |
| Dangerous single words (`book`, `free`, `cancel`) | High | Prefer phrases |
| Phrase triggers (`book a time`, `am I free`) | Medium-Low | Strong default |
| Full sentence triggers | Low | Capture indirect phrasing |
### 4.3 - Trigger Checklist
* Include plural forms.
* Include regional variants.
* Include indirect phrasing.
* Include natural full sentences.
### 4.4 - Read Trigger Context
Use prior conversation to classify intent and route correctly:
* "What's on my calendar today?" -> daily schedule.
* "Create a meeting with Sarah at 3" -> direct create flow.
Pattern: read history -> classify intent -> route handler.
## Part 5: The Ability Lifecycle
`background.py` background daemons now run alongside interactive ability flows.
### 5.1 - Two Runtime Lifecycles
**Interactive Skill / Agent Controlled path**
1. User trigger or the Agent activates `main.py`.
2. Main flow calls `call(self, worker)`.
3. Ability runs interaction logic.
4. Ability exits with `resume_normal_flow()`.
**Background Daemon path**
1. Session starts.
2. Platform auto-starts `background.py` (no hotword).
3. Main flow calls `call(self, worker, background_daemon_mode)`.
4. Daemon runs a continuous `while True` loop for the session lifetime.
### 5.2 - Ability Categories
| Category | Behavior |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| Skill | Standard user-triggered ability. Hotword -> flow -> `resume_normal_flow()`. |
| Agent Controlled | Triggered by the Agent to fill knowledge gaps or delegate actions. |
| Background Daemon | Starts automatically at session start and runs continuously, including during sleep mode. |
| [Local](/building-abilities/local-ability) | Runs on DevKit hardware with direct access to hardware and sandbox restricted Python libraries via `devkit_functions.py`. |
> **Note:** Agent Controlled templates are still being finalized.
### 5.3 - Ability File Structures
| Pattern | Files | Behavior |
| ---------------------------- | --------------------------- | ------------------------------------------------------------ |
| Standard Interactive | `main.py` | Triggered by the user or the Agent, then exits to main flow. |
| Standalone Background Daemon | `background.py` | Runs continuously for monitoring/logging/scheduling. |
| Interactive Combined | `main.py` + `background.py` | Foreground user flow plus background daemon coordination. |
`background.py` must be named exactly `background.py` or it will not be detected.
### 5.4 - Critical `main.py` vs `background.py` Differences
| Aspect | `main.py` | `background.py` |
| ---------------------- | ----------------------- | -------------------------------------------- |
| `call()` signature | `call(self, worker)` | `call(self, worker, background_daemon_mode)` |
| Trigger | User hotword or Agent | Automatic on session start |
| Lifecycle | Run once, then exit | Continuous loop |
| `resume_normal_flow()` | Required on exit paths | Not used in daemon loop |
| Sleep mode | Not active while asleep | Keeps running while Agent sleeps |
### 5.5 - Combined Pattern (`main.py` + `background.py`)
1. User says "set an alarm for 3 PM Thursday."
2. `main.py` parses intent and writes schedule data to `alarms.json`.
3. `main.py` confirms and exits via `resume_normal_flow()`.
4. `background.py` polls `alarms.json` on an interval.
5. At trigger time, background calls `send_interrupt_signal()`, then plays/speaks alert.
6. Background updates alarm status to triggered.
### 5.6 - Background Daemon Best Practices
1. Use `session_tasks.sleep()` instead of `asyncio.sleep()`.
2. Keep poll intervals reasonable (typically 10-30 seconds).
3. Handle missing files gracefully (`check_if_file_exists()` first).
4. For JSON updates, delete then write full content.
5. Log heavily with `editor_logging_handler`.
6. Call `send_interrupt_signal()` before daemon `speak()` or `play_audio()`.
7. Keep a never-ending `while True` loop for sleep-mode continuity.
### 5.7 - The `ability.md` Pattern
Each ability should include `ability.md` with YAML frontmatter and markdown body.
**Critical:** `description` is the primary trigger field for system routing.
## Part 6: Ability Ideas by Location
### 6.1 - Nightstand (Bedroom)
* Morning Manifest
* Lights Out Debrief
* Tomorrow's Weather Whisper
* Bedtime Story Engine
* Midnight Worry Jar
* Gratitude Fade-Out
* Morning Body Check
* Dream Catcher
* Sleep Debt Tracker
* Power Nap Coach
### 6.2 - Living Room (Couple)
* Settle It
* Movie Matchmaker
* Dinner Decider
* Couple's Trivia
* The Argument Cooldown
* Weekend Planner
* Guest Mode
* Anniversary Vault
* Background Narrator
### 6.3 - Kitchen
* Recipe Walkthrough
* Grocery List Builder
* Cooking Timer Orchestrator
* Kitchen Radio DJ
* Sous Chef Advisor
### 6.4 - Conference Room
* Decision Logger
* Action Item Extractor
* Meeting Recap
* Who Talked Most
* Pre-Meeting Briefer
* Follow-Up Drafter
* Agenda Enforcer
* Cross-Meeting Intelligence
### 6.5 - College Dorm
* Study Pomodoro Coach
* Exam Countdown
* Cram Session Quiz Master
* Budget Buddy
* Wake Up Enforcer
* Roommate Mediator
### 6.6 - Home Office
* Focus Guardian
* Standup Generator
* Meeting Prep Briefer
* End-of-Day Wrap
### 6.7 - Car / Commute
* Commute Debrief
* Hands-Free Messenger
* Traffic Aware ETA
* Errand Optimizer
## Part 7: Ability Ideas by User
### 7.1 - Kids (Ages 5-12)
* Homework Helper
* Would You Rather
* Animal Expert
* Story Builder
* Spelling Bee Coach
* Mystery Detective
### 7.2 - Kids Games (Ages 8-10)
* Boss Battle Trivia
* Monster Collector
* Speed Round
* Dungeon Crawler
* Conspiracy Board
### 7.3 - Parents
* Baby Sleep Tracker
* Toddler Vocabulary Tracker
* Family Calendar Sync
* Bedtime Routine Manager
### 7.4 - Elderly Users
* Medication Reminder
* Cognitive Wellness Check
* Family Connection
* Daily Companion
### 7.5 - Professionals
* Executive Brief
* Sales Call Scorer
* Client Meeting Debrief
## Part 8: Ability Ideas by Use Case
### 8.1 - Health and Wellness
* Mood Logger
* Guided Meditation Selector
* Breathing Exercise Coach
* Symptom Tracker
* Voice Health Scanner
### 8.2 - Productivity
* Inbox Zero Coach
* Voice-to-Task
* Weekly Review
* Voice Notes to Structured Docs
### 8.3 - Finance
* Portfolio Pulse
* Spending Tracker
* Trending Stocks
* Bank Balance Reality Check
### 8.4 - Entertainment
* Song of the Day
* Movie/Show Recommender
* Live Sports Companion
* Spotify Time Machine
### 8.5 - Shopping and Logistics
* Price Watcher
* Grocery Auto-Order
* Package Tracker
* Gift Idea Collector
### 8.6 - Smart Home and IoT
* Scene Controller
* Morning Routine
* Security Check
## Part 9: 3rd-Party API Integration
| Category | APIs | What They Enable |
| ------------------------- | --------------------------------------------------- | ----------------------------------------------- |
| Music and Audio | Suno, ElevenLabs, Spotify, Podcast APIs | Song generation, voice, playback, discovery |
| Finance | Plaid, Alpha Vantage, Polygon.io, CoinGecko | Banking, stock prices, portfolio, crypto alerts |
| Calendar and Productivity | Google Calendar, Todoist, Notion, Gmail | Events, tasks, notes, triage |
| Communication | Twilio, Slack, Telegram, SendGrid | SMS, chat, email delivery |
| Media and Content | TMDB, YouTube, NewsAPI, Goodreads | Media discovery and summaries |
| Location and Travel | FlightAware, Google Places, Uber/Lyft, Ticketmaster | Flight, local, rides, events |
| Smart Home | Philips Hue, Nest, SmartThings, IFTTT | Device control and scenes |
| Health | Apple Health, Nutritionix, Headspace, Fitbit | Steps, calories, meditation, sleep |
| AI and Generation | OpenAI, DALL-E, Whisper, ElevenLabs SFX | LLM tasks, images, transcription, SFX |
| Niche | Astrology APIs, Spoonacular, SportRadar, GitHub | Domain-specific utilities |
**Pro tip:** The highest-value abilities often combine 2-3 APIs into one synthesized output.
## Part 10: The OpenClaw Bridge
### 10.1 - What OpenClaw Is
* Locally running desktop AI agent with a large skill ecosystem.
* Can read files, run CLIs, and access local network resources.
* Has registry-based skills for smart home, finance, communication, media, code, logistics, and health.
### 10.2 - Why It Matters
OpenHome is sandboxed. OpenClaw can operate on-device. A bridge unlocks desktop-level agency through voice.
### 10.3 - What the Bridge Unlocks
| Category | Skills Available | Voice Bridge Example |
| ------------- | --------------------------------------- | --------------------------------------------- |
| Smart Home | Hue, IKEA, Nest, Tesla, Govee, Roborock | "Turn off the living room lights" |
| Communication | WhatsApp, Slack, Telegram, email | "Tell Mom I'll be there at 6" |
| Media | Spotify, Plex, Jellyfin | "Play Discover Weekly on living room speaker" |
| Productivity | Google Workspace, GitHub, Notion | "What PRs need my review?" |
| Shopping | Amazon, grocery, price tracking | "Order everything on my grocery list" |
### 10.4 - Flagship Bridge Abilities
* Smart Home Scene Controller
* Send Message by Voice
* Voice-Triggered Email Triage
* Tesla Voice Control
* GitHub Standup
* Voice Clone Creator
* Meeting Notes to Vault
* Document Generator
### 10.5 - Security
* Use permission tiers (read-only, write-confirmed, financial/messaging explicit confirmation).
* Send structured requests, not raw code.
* Vet registry skills carefully before installation.
## Part 11: Combining Frameworks
| Combination | What It Creates | Example |
| ----------------------------------- | --------------------------------------------------- | ------------------------------------------ |
| Observer + Surprise Artifact | Passive intelligence producing unexpected documents | Anniversary Vault, Dream Dictionary |
| Proxy Agent + OpenClaw Bridge | Voice actions in the real world | Send WhatsApp, book rides, order groceries |
| Daily Ritual + Compound Loop | Habits that improve daily | Personalized morning briefing |
| Social Multiplier + Emotional Radar | Group experiences that adapt to room energy | Adaptive party trivia |
| Information Funnel + Context Mesh | One sentence from many data streams | Calendar + weather + traffic + mood |
| Invisible Worker + Graceful Silence | Background intelligence with selective speaking | Flight delay watcher |
**Pro tip:** Start with one primary framework, then add one secondary framework to 10x value.
## Part 12: The Sci-Fi Frontier
These ideas are technically feasible today with ambient audio, diarization, extraction, and longitudinal logging.
* Relationship Autopsy - detect communication pattern shifts before conscious recognition.
* Voice Health Scanner - detect illness signatures from vocal micro-changes.
* Cognitive Decline Watchdog - monitor repetition and word-finding over months.
* Emotional Forecast - predict daily trajectory from morning voice plus context.
* Agent Drift Monitor - track long-term language and interest changes.
* Argument Predictor - identify precursors and intervene before escalation.
* Dream Decoder Network - connect sleep-talk themes with daytime context.
**Insight:** Voice is a biomarker; longitudinal speech can reveal patterns users do not explicitly report.
## Part 13: Quality Checklist
Run this before shipping any Ability:
* [ ] For `main.py` skills: `resume_normal_flow()` called on every exit path.
* [ ] No `print()`; use `editor_logging_handler`.
* [ ] No raw `asyncio`; use `session_tasks`.
* [ ] API calls wrapped in `try/except` with spoken fallback.
* [ ] All network calls include timeout (e.g., `timeout=10`).
* [ ] Exit word detection in looping abilities.
* [ ] `speak()` strings are short and natural aloud.
* [ ] Destructive actions require confirmation loop.
* [ ] Multi-turn flows support cancel (`never mind`, `cancel`).
* [ ] Filler speech before API calls over 1 second.
* [ ] API keys are placeholders, never hardcoded secrets.
* [ ] No blocked imports (`redis`, `user_config`, `open`).
* [ ] File names are ability-namespaced.
* [ ] Read all `speak()` strings out loud during testing.
* [ ] For `background.py` daemons: no `resume_normal_flow()` inside the daemon loop.
* [ ] For `background.py` daemons: call `send_interrupt_signal()` before speaking or playing audio.
* [ ] For `background.py` daemons: use a continuous `while True` loop with `session_tasks.sleep()`.
## Part 14: The Brainstorm Catalog (170+ Ideas)
Format: **Ability Name - Speaker Location - Example User - Description**
### 14.1 - Daily Life and Routines
* Daily Song Generator - Living Room - 20s Woman - Suno-generated hype song summarizing the day.
* Morning Motivation - Nightstand - Entrepreneur - Reads goals and asks for one priority.
* Outfit Advisor - Bedroom - Professional - Weather plus calendar formality suggestion.
* Commute Launcher - Entryway - Office Worker - Traffic, ETA, and podcast queue.
* Arrival Debrief - Living Room - Parent - Welcome recap after returning home.
* Evening Wind-Down - Living Room - Couple - Lights, ambient music, reflective prompt.
* Weekend Kickoff - Living Room - Family - Friday activity suggestions from weather and preferences.
* Bedtime Closer - Nightstand - Anyone - Lock doors, set alarm, preview tomorrow.
* Caffeine Tracker - Kitchen - Coffee Addict - Tracks intake and sleep impact.
* Habit Streak - Any Room - Self-Improver - Daily check-ins and streak announcements.
* Dog Walk Tracker - Entryway - Pet Owner - Tracks walk cadence and weather-aware nudges.
### 14.2 - Work and Productivity
* Standup Bot - Home Office - Developer - Reads git plus calendar and drafts standup update.
* Email Sniper - Home Office - Executive - Voice triage on top email subjects.
* Focus Lock - Home Office - Writer - Blocks interruptions with optional white noise.
* Decision Journal - Home Office - Founder - Logs decisions and 30-day outcome reviews.
* Client Prep - Home Office - Salesperson - CRM context before calls.
* Idea Capture - Any Room - Creative - Timestamped idea logging by project.
* Pitch Practice - Living Room - Startup Founder - Timing and clarity feedback.
* Code Review Reader - Home Office - Developer - Reads PR comments aloud.
* Sprint Closer - Home Office - PM - Sprint summary and retro point generation.
### 14.3 - Finance and Money
* Spending Alarm - Kitchen - Overspender - Alerts when spend exceeds daily budget.
* Bill Countdown - Living Room - Budgeter - Weekly due bills summary.
* Impulse Blocker - Living Room - Shopper - Defers purchases and rechecks next day.
* Side Hustle Tracker - Home Office - Gig Worker - Logs earnings and monthly P and L.
* Subscription Audit - Living Room - Anyone - Monthly recurring subscription breakdown.
* Savings Goal - Living Room - Saver - Goal progress nudges.
* Crypto Morning Brief - Home Office - Trader - Overnight movers and activity summary.
### 14.4 - Health and Wellness
* Stretch Break - Home Office - Desk Worker - Two-minute mobility prompts every 90 minutes.
* Breathing Coach - Bedroom - Anxious Person - Tone-guided breathing pacing.
* Calorie Estimator - Kitchen - Dieter - Meal estimation via nutrition API.
* Symptom Log - Bedroom - Chronic Illness - Daily symptom tracking and weekly report.
* Allergy Alert - Kitchen - Allergy Sufferer - Pollen-aware outdoor warnings.
* Mental Health Check - Bedroom - Anyone - Weekly check-in with monthly patterns.
### 14.5 - Relationships and Social
* Date Night Planner - Living Room - Couple - Budget-aware restaurant and activity suggestions.
* Love Language Tracker - Living Room - Couple - Tracks expression balance over time.
* Friend Tracker - Living Room - Social Person - Nudges for neglected relationships.
* Party DJ - Living Room - Host - Guest requests and playlist control.
* Gift Brain - Any Room - Thoughtful Person - Year-round gift idea capture.
* Anniversary Countdown - Bedroom - Partner - Contextual reminders from past activities.
### 14.6 - Kids and Family
* Chore Quest - Living Room - Family - Gamified chores with XP and leaderboards.
* Vocabulary Builder - Kid's Room - Student (8) - Word of the day with reinforcement.
* Math Duel - Living Room - Siblings - Competitive adaptive mental math.
* Joke of the Day - Kitchen - Family - Daily joke plus weekly best-of.
* Talent Show Host - Living Room - Family - MC flow with applause and scoring.
### 14.7 - Entertainment and Games
* Murder Mystery - Living Room - Dinner Party - Role assignment and clue progression.
* Rap Battle Coach - Bedroom - Teen - Freestyle prompts and judging.
* Sports Bar Mode - Living Room - Sports Fan - Live score narratives and alerts.
* DnD Dungeon Master - Living Room - Gamers - Campaign narration and NPC voices.
* Escape Room - Living Room - Couple - Voice puzzle scenarios with timer and hints.
* Debate Tournament - Living Room - Friends - Timed topics and AI judging.
### 14.8 - Smart Home and Environment
* Room Mood Setter - Living Room - Anyone - Scene orchestration with lights and climate.
* Leaving House Check - Entryway - Forgetful - Lock, lights, thermostat verification.
* Energy Coach - Living Room - Homeowner - Efficiency nudges from usage context.
* Guest Welcome - Entryway - Host - Door-aware welcome and privacy mode.
* Thermostat Negotiator - Living Room - Couple - Fair compromise between preferences.
### 14.9 - Creative and Maker
* Song of the Day - Living Room - 20s Woman - Personalized Suno track.
* Beat Maker - Bedroom - Teen - Vibe-to-beat generation.
* Sound Effect Studio - Any Room - Creator - On-demand SFX generation.
* Writing Prompt - Home Office - Writer - Genre-aware prompt creation.
* Remix My Day - Bedroom - Producer - Ambient track from day transcript.
* Mood Playlist - Living Room - Anyone - Mood-aware playlist generation.
### 14.10 - Background / Always-On
* Life Logger - Any Room - Reflective - Always-on ambient capture with daily summaries.
* Baby Monitor Plus - Nursery - New Parent - Cry detection and breathing/silence alerts.
* Meeting Scribe - Conference - Team - Auto-start with 3+ voices and real-time notes.
* Daily To-Do Compiler - Any Room - Busy Person - Captures "I need to" moments.
* Gratitude Harvester - Any Room - Anyone - Collects positive statements for weekly review.
* Dream Recorder - Bedroom - Dreamer - Sleep-talking capture into journal.
* Profanity Jar - Living Room - Family - Running tally with playful fines.
### 14.11 - Niche and Weird
* Wine Pairing - Kitchen - Foodie - Meal-to-wine recommendation.
* Dad Joke Engine - Kitchen - Dad - Endless joke generator with groan scoring.
* Plant Care - Any Room - Plant Parent - Species-specific care reminders.
* Hot Take Generator - Living Room - Friends - Debate-fueling spicy prompts.
* Life Narrator - Any Room - Anyone - Stylized narration mode.
* Compliment Machine - Bathroom - Anyone - Daily compliment on detection.
* Random Fact Cannon - Kitchen - Family - Timed obscure fact drops.
## Closing Thought
The best Ability does something the LLM cannot, at a moment the user did not expect, using context accumulated over time, delivered in fewer words than the user would use.
Build for the room. Build for the moment. Build for the silence between words.
Then let the speaker do what it does best: be there.
# Connect to Hermes
Source: https://docs.openhome.com/building-abilities/hermes
Talk to your local Hermes Agent (Nous Research) through an OpenHome voice device — full setup, bridge configuration, and the marketplace template.
Hermes lets your OpenHome agent answer questions and run tasks through your local **Hermes Agent** from [Nous Research](https://github.com/nousresearch/hermes-agent). You ask a question out loud, Hermes answers using its tools, memory, and skills, and the reply is spoken back in natural, conversational language.
```
You speak → OpenHome (cloud) → hermes_bridge.py (your PC) → Hermes Agent
↑ │
└────────────────── spoken reply ─────────────────┘
```
OpenHome runs in the cloud and can't reach `localhost` on your machine, so a small **bridge** runs on your PC. It holds a WebSocket to OpenHome, forwards each question to Hermes, and sends the answer back. The bridge auto-detects how to reach Hermes and needs **no separate API server** in the common case.
For the lighter-weight terminal alternatives, see [Connect to OpenClaw](/building-abilities/openclaw) and [Local Connect](/building-abilities/local-connect).
## What you can build
* A voice front-end for your Hermes Agent's tools, memory, and skills
* Hands-free system queries (disk usage, running processes, file lookups)
* Conversational research and Q\&A with follow-up questions
* Voice-driven developer workflows backed by Hermes' tool calls
* Any task your Hermes install can already do — now spoken
## How it works
The integration has two pieces:
| File | Runs where | Purpose |
| ------------------ | ---------------- | ----------------------------------------------------- |
| `main.py` | OpenHome (cloud) | The Ability. Installed from the Marketplace template. |
| `hermes_bridge.py` | Your PC | Connects OpenHome to local Hermes. All-in-one. |
### The Ability (`main.py`)
1. **Trigger** — you say the wake phrase (e.g. *"hermes"*). OpenHome activates the Ability.
2. **Intent check** — an LLM classifier looks at what you said:
* If you only said the wake word (*"hermes"*), it asks *"What would you like to ask?"*
* If you said a full question (*"hermes, what's my disk usage"*), it sends that straight to Hermes — no extra prompt.
3. **Conversation loop** — after the first answer it keeps listening, so you can ask follow-up questions without repeating the wake word.
4. **Exit** — an LLM classifier detects when you're done (*"stop"*, *"that's all"*, *"goodbye"*, *"never mind"*), says goodbye, and hands control back to your normal OpenHome assistant.
5. **Speakable output** — Hermes' raw output (which can contain tables, file paths, and symbols) is rewritten by an LLM into a short, natural spoken reply before it's read aloud.
### The bridge (`hermes_bridge.py`)
On startup the bridge picks a backend automatically:
1. **API mode** — if a live OpenAI-compatible endpoint answers (Hermes proxy, Open WebUI, or anything at `HERMES_API_URL`), it POSTs to `/v1/chat/completions`.
2. **CLI mode** *(default for a standard Anthropic / Claude-Code Hermes setup)* — if no endpoint is found, it runs `hermes -z ""` directly as a subprocess. No proxy, no login, no extra server.
3. **Error** — if neither a live endpoint nor the `hermes` binary exists, it reports a clear error instead of failing silently.
It also **warms up** Hermes at startup (the first agent call is a slow cold start) and **auto-falls back** from API to CLI mode if a chosen endpoint dies.
## Setup
### 1. Install Hermes
Install the Hermes Agent from [Nous Research](https://github.com/nousresearch/hermes-agent) and follow its setup instructions. Configure it with an LLM API key (Anthropic, OpenAI, etc.) as described in the project's README.
Verify the one-shot mode runs:
```bash theme={"system"}
hermes -z "say hello"
```
If that prints a reply, the bridge's CLI mode will work.
### 2. Install the bridge dependencies
You need Python 3.8+ on the machine running the bridge:
```bash theme={"system"}
python3 --version
pip install websockets httpx
```
### 3. Get the bridge
[hermes\_bridge.py](https://drive.google.com/file/d/1pEwGb4jO9tkS1_7ZwZl62OJ4eUEizwzG/view?usp=sharing). Save it anywhere convenient — `~/openhome/` on macOS/Linux, `C:\openhome\` on Windows.
The source for both the bridge and the Ability also lives on GitHub: [openhome-dev/abilities/templates/hermes](https://github.com/openhome-dev/abilities/tree/dev/templates/hermes).
### 4. Configure and run the bridge
Copy it from [Dashboard → Settings → API Keys](https://app.openhome.com/dashboard/settings), then export it **before** launching the bridge:
```bash theme={"system"}
export OPENHOME_API_KEY="oh_xxx..."
```
```bash theme={"system"}
python3 hermes_bridge.py
```
Expected output:
```
... Probing API endpoint: http://localhost:8645/v1
... no response.
... No API endpoint found; using CLI mode (hermes -z).
... Warming up Hermes (first call initializes the agent)...
... Hermes is warm.
... Connecting to OpenHome at wss://app.openhome.com/ws/local_link/ ...
... Connected to OpenHome [backend=cli]. Waiting for inquiries.
```
Keep this terminal open while using the Ability.
**Keep it alive across sessions:**
* **macOS/Linux** — use `tmux` or `screen`, or run in the background: `nohup python3 hermes_bridge.py > ~/hermes_bridge.log 2>&1 &`
* **Windows** — run in a minimized terminal, or via Task Scheduler
### 5. Install the Hermes Ability
Add the ready-made Hermes template from the Marketplace:
Dashboard → **Marketplace**.
Find the **Hermes** ability template and add it to your account.
Open it in the **Live Editor** if you want to review or customize the code.
Set **Trigger Words** — e.g. `hermes`, `ask hermes`, `hey hermes`.
Enable the Ability on your agent.
To edit the Ability later, use the OpenHome **Live Editor** — no re-upload needed.
## Using it
Say the wake word, with or without a question:
> **"Hermes."**
> *"Hermes here. What would you like to ask?"*
> **"What's my disk usage and the biggest folders in my home directory?"**
> *"Your main drive is about 72 percent full, with roughly 64 gigabytes free. The largest folders are Videos and Projects, each around 28 gigabytes."*
> **"What time is it?"**
> *"It's 1:42 in the afternoon."*
> **"That's all, thanks."**
> *"Okay, leaving Hermes. Goodbye."*
You don't repeat the wake word for follow-ups — the loop keeps listening until you signal you're done.
## Configuration reference
All settings are environment variables (set them before launching the bridge), or edit the constants in `Config` at the top of `hermes_bridge.py`.
| Variable | Default | Meaning |
| ------------------------- | ------------------ | ---------------------------------------------------------- |
| `OPENHOME_API_KEY` | *(required)* | Your OpenHome API key. |
| `OPENHOME_HOST` | `app.openhome.com` | OpenHome host. |
| `OPENHOME_CLIENT_ID` | `laptop` | Device id; must match the Ability's `target_id`. |
| `OPENHOME_ROLE` | `agent` | Connection role. |
| `HERMES_API_URL` | *(empty)* | Force API mode against this base URL (e.g. a proxy/WebUI). |
| `HERMES_API_KEY` | *(empty)* | Bearer token if your API endpoint needs one. |
| `HERMES_MODEL` | `hermes-agent` | Model name sent in API mode. |
| `HERMES_BIN` | `hermes` | Path to the hermes CLI for CLI mode. |
| `HERMES_CLI_EXTRA` | *(empty)* | Extra CLI args, e.g. `--yolo` to auto-approve tool calls. |
| `HERMES_TIMEOUT` | `180` | Seconds to wait for a Hermes answer. |
| `API_PROBE_TIMEOUT` | `2` | Seconds per endpoint probe at startup. |
| `HERMES_SPEAKABLE_HINT` | `1` | `0` disables the "answer concisely for speech" hint. |
| `HERMES_BRIDGE_LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, ...). |
## Troubleshooting
The bridge isn't running, or Hermes isn't installed/working. Test with `hermes -z "say hi"`.
The `hermes` binary isn't on PATH. Install Hermes, or set `HERMES_BIN` to its full path, or run an API endpoint and set `HERMES_API_URL`.
Cold start. The bridge's warm-up should prevent this; if it persists, raise `HERMES_TIMEOUT`.
Hermes may be waiting for interactive approval. Run with auto-approve:
```bash theme={"system"}
HERMES_CLI_EXTRA="--yolo" python3 hermes_bridge.py
```
Keep `HERMES_SPEAKABLE_HINT=1` (default); the Ability also rewrites output for speech. Ask narrower questions for tighter answers.
Wrong/expired `OPENHOME_API_KEY`, or your account uses a different local-connect host. Check the dashboard.
Give each bridge a distinct `OPENHOME_CLIENT_ID` and pass a matching `target_id` from the Ability.
## Security & privacy
The bridge runs Hermes with **your** user permissions — it can run shell commands, read/write files, and more. Only trigger it with requests you'd be comfortable running yourself.
* `--yolo` auto-approves tool calls. Convenient for voice, but it removes the manual confirmation step — enable it knowingly.
* Treat your `OPENHOME_API_KEY` as a secret. If it has ever been pasted somewhere public, rotate it in the dashboard.
* Anyone with access to your OpenHome account can reach Hermes through your bridge while it's running.
## Architecture
```
Voice Input → OpenHome Ability (main.py)
↓ WebSocket
hermes_bridge.py (your PC)
↓
API mode CLI mode
POST /v1/chat/completions hermes -z ""
↓
Hermes Agent
(tools · memory · skills)
↓
Response ← AI formatting ← Ability
```
## Resources
* **Hermes Agent** (Nous Research): [github.com/nousresearch/hermes-agent](https://github.com/nousresearch/hermes-agent)
* **Hermes template** on GitHub: [openhome-dev/abilities/templates/hermes](https://github.com/openhome-dev/abilities/tree/dev/templates/hermes)
* **Download the bridge:** [`hermes_bridge.py`](https://drive.google.com/file/d/1pEwGb4jO9tkS1_7ZwZl62OJ4eUEizwzG/view?usp=sharing)
* **Lighter alternatives:** [Connect to OpenClaw](/building-abilities/openclaw) · [Local Connect](/building-abilities/local-connect)
# Hot Mic + Deepgram
Source: https://docs.openhome.com/building-abilities/hot-mic-deepgram
Capture raw audio from the DevKit mic and send it to Deepgram for diarization, sentiment, topic detection, and more.
OpenHome Abilities have access to **raw audio recording** from the device microphone — independent of the built-in STT pipeline. This means an Ability can:
1. **Start recording** while OpenHome's normal conversation flow keeps running
2. **Capture audio for seconds, minutes, or hours** — as long as the Ability is active
3. **Retrieve the raw audio bytes** when recording stops
4. **Send those bytes anywhere** — Deepgram, ElevenLabs, any audio API
Until now, Abilities could only access what the user *said* (as text, after STT processed it). Now they can access what the microphone *heard* — multiple speakers, background sounds, music, ambient noise, tone of voice, everything. Combined with **Deepgram's API** (Nova-3 transcription, diarization, sentiment, topic detection, summarization), this unlocks an entirely new class of Abilities.
## The core pattern
Every hot-mic Ability follows the same architecture:
```python theme={"system"}
# 1. Start recording (mic stays hot — OpenHome STT still works in parallel)
self.capability_worker.start_audio_recording()
# 2. Do whatever you need while recording runs...
# - Listen for a stop command via user_response()
# - Run a timer with session_tasks.sleep()
# - Continue normal conversation
# 3. Stop recording
self.capability_worker.stop_audio_recording()
# 4. Get the raw audio bytes
audio_bytes = self.capability_worker.get_audio_recording()
recording_length = self.capability_worker.get_audio_recording_length()
# 5. Send to Deepgram (or any audio API)
response = requests.post(
"https://api.deepgram.com/v1/listen",
headers={
"Authorization": "Token YOUR_DEEPGRAM_KEY",
"Content-Type": "audio/wav",
},
params={
"model": "nova-3",
"diarize": "true",
"smart_format": "true",
"utterances": "true",
"punctuate": "true",
"language": "en",
},
data=audio_bytes,
)
deepgram_result = response.json()
```
OpenHome handles the mic. Deepgram handles the intelligence. Your Ability handles the logic.
## What Deepgram gives you
When you send audio to Deepgram's pre-recorded endpoint, you're not just getting text back. Depending on the parameters you pass:
| Feature | Parameter | What it returns |
| ----------------------- | ---------------------- | ------------------------------------------------------------ |
| **Speaker diarization** | `diarize=true` | Labels each segment with a speaker ID (Speaker 0, Speaker 1) |
| **Utterances** | `utterances=true` | Groups speech into speaker turns with timestamps |
| **Smart formatting** | `smart_format=true` | Adds punctuation, capitalization, paragraph breaks |
| **Keyword boosting** | `keyterm=["OpenHome"]` | Improves accuracy for domain-specific words |
| **Language detection** | `detect_language=true` | Auto-detects the spoken language |
| **Summarization** | `summarize=v2` | Auto-generated summary of the audio |
| **Topic detection** | `detect_topics=true` | Identifies topics discussed |
| **Sentiment analysis** | `sentiment=true` | Positive / negative / neutral per utterance |
| **Word timestamps** | *always included* | Start/end time for every word |
All features can be combined in a single API call — a diarized, summarized, sentiment-analyzed transcript with topic labels and keyword boosting, all from the same audio bytes.
## Recording methods
| Method | What it does |
| ------------------------------ | ------------------------------------------------------- |
| `start_audio_recording()` | Opens the mic buffer. Recording runs in the background. |
| `stop_audio_recording()` | Closes the mic buffer. |
| `get_audio_recording()` | Returns raw audio as `bytes`. |
| `get_audio_recording_length()` | Returns duration in seconds. |
| `flush_audio_recording()` | Clears the buffer so the next recording starts fresh. |
OpenHome's normal STT/TTS pipeline **keeps running** while recording is active. The user can still talk to OpenHome, trigger other commands, and interact normally. Recording happens in parallel — a background capture, not a modal takeover.
## Showcase examples
Five Ability ideas built on this pattern. For the full catalog of 20+, see the [Simple Abilities Cookbook](/building-abilities/cookbook).
### 1. Meeting Notes
*"Hey OpenHome, take notes."* Records the entire meeting. On "meeting finished," sends audio to Deepgram with diarization. Returns formatted notes with speaker labels, summary, and action items.
```python theme={"system"}
self.capability_worker.start_audio_recording()
while True:
user_input = await self.capability_worker.user_response()
if "meeting finished" in user_input.lower():
break
self.capability_worker.stop_audio_recording()
audio_bytes = self.capability_worker.get_audio_recording()
# → Send to Deepgram with diarize=true, utterances=true
```
### 2. Baby Monitor
*"Hey OpenHome, listen to the nursery."* Records ambient audio on a rolling basis (e.g., 30-second windows). Analyzes each window for crying, coughing, or silence-breaking events. Alerts via TTS.
```python theme={"system"}
while self.is_monitoring:
self.capability_worker.start_audio_recording()
await self.worker.session_tasks.sleep(30)
self.capability_worker.stop_audio_recording()
audio_bytes = self.capability_worker.get_audio_recording()
# Analyze with Deepgram or sound classifier
# If event detected → alert via speak()
```
### 3. Public Speaking Coach
*"Hey OpenHome, coach my presentation."* Records a practice run. Analyzes pace (words per minute from timestamps), filler count ("um", "uh", "like"), and sentence variation. LLM generates coaching notes.
### 4. Voice Journal
*"Hey OpenHome, start my journal."* Records a free-form spoken entry. Transcribes with Deepgram, then LLM formats it into a clean journal entry with date, mood detection (sentiment), and key topics.
### 5. Noise Level Monitor
*"Hey OpenHome, monitor the noise level."* Analyzes raw PCM amplitude without any external API. Reports quiet stretches and noisy spikes. Triggers focus reminders.
```python theme={"system"}
import struct
samples = struct.unpack(f"<{len(audio_bytes)//2}h", audio_bytes)
peak = max(abs(s) for s in samples)
rms = (sum(s**2 for s in samples) / len(samples)) ** 0.5
```
## Why this matters
Before the hot mic, Abilities were **reactive** — they activated on a trigger word, had a conversation, and exited. The microphone was a command input device.
Now the microphone is a **sensor**. It can run continuously, capture rich audio data, and feed it to external intelligence services. This turns OpenHome from a voice assistant into an **ambient computing platform**.
Because the output is just bytes, you can send them anywhere:
* **[Deepgram](https://deepgram.com)** for transcription, diarization, sentiment, topics, summarization
* **ElevenLabs** for voice cloning (already used in AI Twin)
* **Any sound classification API** for non-speech audio events
* **Your own models** for custom audio analysis
* **Local processing** for amplitude analysis, silence detection, etc.
## See also
* [Background Abilities](/building-abilities/background-abilities) — pair hot-mic recording with an always-on daemon
* [Simple Abilities Cookbook](/building-abilities/cookbook) — the full 20-idea catalog
* [SDK Reference](/api-sdk/sdk-reference) — full method reference
# How to Build an Ability
Source: https://docs.openhome.com/building-abilities/how-to-build
Learn to build and integrate custom Abilities in OpenHome, using `CapabilityWorker` and example use cases.
## Introduction
Custom Abilities are the cornerstone of extending OpenHome's functionality. They allow developers to:
* Add personalized features to AI agents.
* Integrate third-party APIs for dynamic interactions.
* Customize logic for enhanced user engagement.
This guide walks you through:
* Structuring and registering an Ability.
* Using `CapabilityWorker` for seamless I/O management.
* Examples showcasing how to create powerful custom Abilities.
***
## Adding an Ability
### File Structure
Each Ability resides in its folder and requires a `main.py` file to define the logic.
```plaintext theme={"system"}
|── __ init __.py
|── README.md
└── main.py
```
### Example File: `main.py`
Here’s a basic template for building a new Ability:
```python theme={"system"}
import json
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
class YourCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
# Do not change following tag of register capability
#{{register capability}}
def call(
self,
worker: AgentWorker,
):
# Your capability logic here
return "Your Capability Called!"
```
### Key Components
* **`#{{register_capability}}`**: is essential.
* **`call`**: Executes the Ability’s logic when triggered.
## Custom API Keys (Third-Party Services)
If your Ability needs credentials for external services (for example OpenAI, SendGrid, or Twilio), configure those keys in the dashboard and read them at runtime using `get_api_keys("key_name")`.
### Setup Flow
#### For Developers
**Step 1 — Declare keys**
You can declare a custom API key in either of two ways:
* **While creating/editing the Ability:** Under **Ability Behavior → API Keys**, add each key by name and include a provider URL (required). The key *value* is not set here; values are always managed from **Settings → API Keys**.
* **From Settings:** Go to **Settings → API Keys → Third-party Keys** and create the key directly, then link it to an Ability by creating or editing one in the Ability editor.
**Step 2 — Tag keys as required**
After declaring a key, under **Ability Behavior → API Keys**, *tag* the API key to mark it as required. This tells the platform to prompt users for the value at install time.
> **Important:** Untagged keys will not trigger an install-time prompt for users.
**Step 3 — Read values at runtime**
```python theme={"system"}
openai_key = self.capability_worker.get_api_keys("openai_api_key")
if not openai_key:
self.worker.editor_logging_handler.warning("Missing openai_api_key")
```
***
#### For Users
When installing an Ability from the marketplace, a pop-up lists all required keys with direct links to each provider. Users can enter values immediately or skip and add them later from **Settings → API Keys**. The Ability will not work until all required keys have values set.
> **Security note:** Never hardcode secrets in your Ability code. Always read keys at runtime.
***
## Reading Linked Account Tokens with `get_token()`
For providers that support OAuth-based account linking, OpenHome stores the user's access token on the user's behalf when they connect the account from **Settings → Linked Accounts** in the Dashboard. Inside an Ability, use `get_token()` to retrieve that token and call the provider's API on behalf of the user without handling the OAuth flow yourself.
This is different from **Custom API Keys** above: API keys are values the user pastes in for arbitrary third-party services, while `get_token()` returns an OAuth access token managed by OpenHome for a supported provider.
### Signature
```python theme={"system"}
get_token(platform: str) -> str | None
```
`get_token()` is **synchronous** — do not `await` it.
### Parameters
| Parameter | Type | Description |
| ---------- | ----- | ----------------------------------------------------------------------------------------- |
| `platform` | `str` | The provider key. One of `"google"`, `"slack"`, `"discord"`, `"microsoft"`, or `"tesla"`. |
### Returns
* A non-empty `str` access token if the user has linked the requested provider.
* `None` or an empty value if the provider is not linked for the user. Always guard the return value before using it.
### Example: token lookup with a connect-account fallback
The recommended pattern is to read the token at the start of `run()` and, if it is missing, speak a clear instruction telling the user where to connect the account, then exit cleanly.
```python theme={"system"}
async def run(self):
token = self.capability_worker.get_token("google")
if not token:
await self.capability_worker.speak(
"Your Google account isn't connected yet. "
"Please connect it in Settings, Linked Accounts, on the OpenHome Dashboard, then try again."
)
self.capability_worker.resume_normal_flow()
return
# Token is available — call the provider's API as needed.
# ... use `token` to call the provider API ...
self.capability_worker.resume_normal_flow()
```
Substitute the provider key (`"slack"`, `"discord"`, `"microsoft"`, `"tesla"`) and the spoken instruction to match the account your Ability needs.
### Reference Abilities Using `get_token()`
The following community Abilities use `get_token("google")` in production. Review them for end-to-end patterns covering token retrieval, API calls, and error handling:
* [Morning Brief](/community/abilities/morning-brief) — reads Gmail and Google Calendar to generate a daily summary.
* [Gmail Voice Assistant](/community/abilities/gmail-voice-assistant) — voice-driven Gmail inbox management.
* [Google Calendar](/community/abilities/google-calendar) — view, create, and update calendar events by voice.
* [Google Tasks](/community/abilities/google-tasks) — manage Google Tasks lists by voice.
For the user-facing account-linking flow, see [Linked Accounts](/dashboard#linked-accounts) in the Dashboard reference.
***
## Making HTTP Requests
When your Ability calls an API, **always** route the request through `self.worker.session_tasks`. This lets the SDK manage the request for you: it paces outbound calls and smooths out latency, so a slow or heavy API doesn't block your Agent or affect other Abilities.
Do not call `requests`, `httpx`, or `aiohttp` directly — use the matching `session_tasks` helper instead. This applies to code you write by hand and to code generated by an AI assistant.
The simplest option is `get()`. It works just like the `requests` library: give it a URL and read the response with `.json()`.
```python theme={"system"}
resp = self.worker.session_tasks.get("https://api.example.com/data", timeout=5)
data = resp.json()
```
This is all most Abilities need. `get()` accepts the usual arguments (`params`, `headers`, `timeout`, `auth`) and returns a standard `requests.Response`.
### Which helper should I use?
Most Abilities can just use `get()`. You only need the others if you want an async call or prefer a different library. When you do, the choice depends on how your method is written:
* **Normal `def` method** → use a **sync** helper and call it directly, with no `await`: `get()` or `httpx_get()`.
* **`async def` method** (for example, a background-daemon loop) → use an **async** helper and `await` it: `get_async()`, `httpx_get_async()`, or `aiohttp_get_async()`. Async helpers don't freeze the rest of your Ability while the request is running, so prefer them whenever your method is already async.
`requests` has no async version of its own, so `get_async()` is there to give you a `requests`-style request that you can `await`.
Every library has the same helpers, so pick the one you're comfortable with:
| Library | Sync | Async |
| -------- | ------------- | --------------------- |
| requests | `get()` | `get_async()` |
| httpx | `httpx_get()` | `httpx_get_async()` |
| aiohttp | — | `aiohttp_get_async()` |
### Examples
```python theme={"system"}
# requests (sync)
resp = self.worker.session_tasks.get("https://api.example.com/data", timeout=5)
data = resp.json()
# requests (async, inside an `async def`)
resp = await self.worker.session_tasks.get_async("https://api.example.com/data")
data = resp.json()
# httpx (sync)
resp = self.worker.session_tasks.httpx_get("https://api.example.com/data", params={"q": "test"})
data = resp.json()
# httpx (async, inside an `async def`)
resp = await self.worker.session_tasks.httpx_get_async(
"https://api.example.com/data",
headers={"Authorization": f"Bearer {token}"},
)
data = resp.json()
# aiohttp (async, inside an `async def`)
resp = await self.worker.session_tasks.aiohttp_get_async("https://api.example.com/data")
data = await resp.json() # with aiohttp, reading the body is also awaited
```
With `requests` and `httpx`, you read the response directly with `.json()`, `.text`, and `.status_code`. With `aiohttp`, reading the body is also asynchronous, so use `await resp.json()` and `await resp.text()`.
## Understanding `CapabilityWorker`
The `CapabilityWorker` class simplifies I/O interactions, enabling:
* **Speech synthesis**: Using text-to-speech (TTS).
* **Listening for user input**: Capturing and processing responses.
* **Running interaction loops**: Supporting conversational flows.
***
## CapabilityWorker Quick Reference
Use these functions directly on `self.capability_worker`.
### Conversation
Speak text to the user using the configured TTS.
```python theme={"system"}
async def speak(self, tokens: str, file_content: str = None):
```
Wait for a single user reply and return the transcription.
```python theme={"system"}
async def user_response(self):
```
Speak once, then wait for one reply.
```python theme={"system"}
async def run_io_loop(self, tokens: str):
```
Ask a yes/no question and return `True` or `False`.
```python theme={"system"}
async def run_confirmation_loop(self, tokens: str):
```
Wait for the user's full transcription.
```python theme={"system"}
async def wait_for_complete_transcription(self):
```
Get full session message history.
```python theme={"system"}
def get_full_message_history(self):
```
Get the current user's timezone.
```python theme={"system"}
def get_timezone(self):
```
Get the user's approximate location from their IP. Returns a dict with `city`, `region`, `country`, `postal`, `timezone`, `ip`, `loc`, and more.
```python theme={"system"}
def get_region_data(self):
```
Get linked account access token.
```python theme={"system"}
def get_token(self, platform: str):
```
Get a custom API key value by key name.
```python theme={"system"}
def get_api_keys(self, key_name: str):
```
Append context/instructions to the active Agent prompt.
```python theme={"system"}
def update_personality_agent_prompt(self, prompt_addition):
```
Return control back to normal Agent flow when your ability is done.
```python theme={"system"}
def resume_normal_flow(self):
```
### Text Generation
Return plain text from the model (no speech).
```python theme={"system"}
def text_to_text_response(self, prompt_text: str, history: list = [], system_prompt: str = ""):
```
Alternate text generation routed through OpenRouter.
```python theme={"system"}
def generate_ttt_using_openrouter(self, prompt_text: str, system_prompt: str = "", history: list = []):
```
Web-search-backed short answer.
```python theme={"system"}
def llm_search(self, query: str, system_prompt: str = "", history: list = []) -> str:
```
Tool-calling flow.
```python theme={"system"}
def llm_tools(self, query: str, tools: list, system_prompt: str = "", history: list = []):
```
### File Helpers
Read a file.
```python theme={"system"}
async def read_file(self, file_name: str, in_ability_directory=False):
```
Write content to a file.
```python theme={"system"}
async def write_file(self, file_name: str, content: str = None, in_ability_directory=False, mode: str = "a+"):
```
Delete a file.
```python theme={"system"}
async def delete_file(self, file_name: str, in_ability_directory=False) -> bool:
```
Check if a file exists.
```python theme={"system"}
async def check_if_file_exists(self, file_name: str, in_ability_directory=False) -> bool:
```
List user data files.
```python theme={"system"}
async def get_user_data_file_names(self) -> list:
```
> **Note: Storage Scope Usage**
>
> * Use `in_ability_directory=False` for persistent user-level storage shared across abilities.
> * Use `in_ability_directory=True` for ability-scoped data that should remain isolated within the ability session.
### Context Storage (Key-Value)
```python theme={"system"}
def create_key(self, key: str, value: dict):
def update_key(self, key: str, value: dict):
def delete_key(self, key: str):
def get_all_keys(self):
def get_single_key(self, key: str):
```
### Audio and Streaming
```python theme={"system"}
async def text_to_speech(self, prompt, voice_id): # speak with a specific voice for this call only
async def play_audio(self, file_content): # play raw audio bytes or a file-like object
async def play_from_audio_file(self, file_name: str = None): # play an mp3 bundled in the Ability directory
async def send_audio_data_in_stream(self, file_content, chunk_size=4096): # low-level chunked streaming, used internally by play_audio() and speak()
async def stream_init(self): # open the audio stream before sending chunks
async def stream_end(self): # close the stream and wait for the client to finish playback
def get_audio_recording(self): # the latest mic recording as .wav bytes
def get_audio_recording_length(self): # duration in seconds of the latest recording
def flush_audio_recording(self): # clear the current recording before a new capture
```
Reach for `play_from_audio_file()` or `play_audio()` for short audio bundled with your Ability, such as a chime, a sound effect, or a recorded line. Reach for `stream_music_from_url()` below for the minutes-long, interruptible case.
### Music Playback
For audio that runs for minutes and can be interrupted by voice, use `stream_music_from_url`. It plays a progressive mp3 link, blocks until playback genuinely ends, and reports why it ended. Your Ability owns only the branch on `outcome`. The audio pipeline, the device buffer, music mode, and the recovery afterwards are all inside the call.
```python theme={"system"}
async def stream_music_from_url(self, url, auth, announce=""):
```
**Play a track:**
```python theme={"system"}
result = await self.capability_worker.stream_music_from_url(
url,
"", # Authorization header value
announce="Playing Blinding Lights.")
result["outcome"] # "finished" | "paused" | "stopped" | "unplayable" | "error"
result["position"] # float seconds heard, for logs
result["sent"] # int bytes delivered, for logs
```
| Parameter | Required | What it's for |
| ---------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | yes | A progressive mp3 link, not an HLS or DASH manifest. Also the resume signal: the same url carries on, any other url starts a new track. |
| `auth` | yes | An `Authorization` header value, sent verbatim. Pass `""` where the host wants none. Positional rather than defaulted, on purpose. |
| `announce` | no | Spoken before playback starts, and only after the link is proven to deliver audio. Leave empty on a resume. |
Returns exactly `{"outcome", "position", "sent"}`. `position` is measured at the halt instant rather than after cleanup, so it is an honest number for logs. Resuming needs nothing from it.
| `outcome` | Means | Usual response |
| ------------ | ------------------------------ | ------------------------------------------- |
| `finished` | played to the end | queue the next track, or ask what's next |
| `paused` | user wants it held | ask, then resume or leave |
| `stopped` | user wants playback over | acknowledge, stop asking |
| `unplayable` | the link never delivered audio | try the next candidate track |
| `error` | something failed mid-stream | apologise once, do not retry the same track |
While a stream is live the Ability is in music mode: it receives no transcriptions, and must not call `speak()` or `run_io_loop()`. Speak before the call or after it returns. However playback ends, including the error path, the client is audible again and the platform is listening when the call returns.
No duration is asked for, and no position is tracked. The engine reads the byte rate off the mp3's own frame header, so a track with missing or wrong metadata still plays and resumes correctly.
Do not assume a stream url handed back by an authenticated API is self-signed. SoundCloud's is not: without the header its CDN answers `401`, and `429` once a few tracks have been tried in a row. If every track comes back `unplayable`, check `auth` first.
**A resume is the same call again with the same `url`**, which makes playback a loop. Four rules live in your code, not in the function:
1. **Resolve the `url` once per track, not per pass.** A fresh url reads as a new track and restarts it from the top. This is the one cost of the url being the resume signal: if a signed link expires while the user sits paused, re-resolving restarts the track.
2. **Only `"paused"` loops.** `finished`, `stopped`, `error`, and `unplayable` all leave.
3. **An unclear pause reply should stop, not resume.** A user who asked for silence must not get the track back because a reply did not parse.
4. **`announce` on the first pass only**, or every resume re-announces the title.
To end playback from your own code (a timer, a device event), set the same events the platform sets, from a task other than the one awaiting the stream:
```python theme={"system"}
self.capability_worker.pause_music() # the live call returns "paused"
self.capability_worker.stop_music() # the live call returns "stopped"
```
See [Example 4: Music Playback](#example-4-music-playback) for the complete Ability with the loop already written, or start from the [`music-template`](https://github.com/openhome-dev/abilities/tree/dev/templates/music-template) directly. Only two functions are yours to fill in:
```python theme={"system"}
def search_track(self, request) # {"id", "title", "artist"}, or None if there isn't one
def stream_url(self, track) # a fresh progressive mp3 link for that track
```
Everything else in that file, the pause menu, the `outcome` branch, the `resume_normal_flow()` on exit, can be left alone.
### WebSocket / Device Actions
```python theme={"system"}
async def send_data_over_websocket(self, data_type: str = "", data: dict = {}):
async def send_interrupt_signal(self):
async def send_devkit_action(self, action: str = ""):
async def send_devkit_mqtt_action(self, topic: str = "", action: str = "", value: str = "", command: str = ""):
async def send_notification_to_ios(self, title: str = "", body: str = "", time_interval: int = 1):
async def send_agent_message_without_audio(self, value: str):
```
### Using Specific Voice IDs for Text-to-Speech
The CapabilityWorker class supports the use of specific Voice IDs for text-to-speech (TTS) functionality. This allows you to customize the voice used for speech synthesis by specifying a Voice ID from the provided list.
#### Available Voice IDs
You can use any of the following Voice IDs for TTS:
```json theme={"system"}
{
"voices": [
{
"voice_id": "21m00Tcm4TlvDq8ikWAM",
"labels": {
"accent": "american",
"description": "calm",
"age": "young",
"gender": "female",
"use case": "narration"
}
},
{
"voice_id": "29vD33N1CtxCmqQRPOHJ",
"labels": {
"accent": "american",
"description": "well-rounded",
"age": "middle aged",
"gender": "male",
"use case": "news"
}
},
{
"voice_id": "2EiwWnXFnvU5JabPnv8n",
"labels": {
"accent": "american",
"description": "war veteran",
"age": "middle aged",
"gender": "male",
"use case": "video games"
}
},
{
"voice_id": "5Q0t7uMcjvnagumLfvZi",
"labels": {
"accent": "american",
"description": "ground reporter",
"age": "middle aged",
"gender": "male",
"use case": "news"
}
},
{
"voice_id": "AZnzlk1XvdvUeBnXmlld",
"labels": {
"accent": "american",
"description": "strong",
"age": "young",
"gender": "female",
"use case": "narration"
}
},
{
"voice_id": "CYw3kZ02Hs0563khs1Fj",
"labels": {
"accent": "british-essex",
"description": "conversational",
"age": "young",
"gender": "male",
"use case": "video games"
}
},
{
"voice_id": "D38z5RcWu1voky8WS1ja",
"labels": {
"accent": "irish",
"description": "sailor",
"age": "old",
"gender": "male",
"use case": "video games"
}
},
{
"voice_id": "EXAVITQu4vr4xnSDxMaL",
"labels": {
"accent": "american",
"description": "soft",
"age": "young",
"gender": "female",
"use case": "news"
}
},
{
"voice_id": "ErXwobaYiN019PkySvjV",
"labels": {
"accent": "american",
"description": "well-rounded",
"age": "young",
"gender": "male",
"use case": "narration"
}
},
{
"voice_id": "GBv7mTt0atIp3Br8iCZE",
"labels": {
"accent": "american",
"description": "calm",
"age": "young",
"gender": "male",
"use case": "meditation"
}
},
{
"voice_id": "IKne3meq5aSn9XLyUdCD",
"labels": {
"accent": "australian",
"description": "casual",
"age": "middle aged",
"gender": "male",
"use case": "conversational"
}
},
{
"voice_id": "JBFqnCBsd6RMkjVDRZzb",
"labels": {
"accent": "british",
"description": "raspy",
"age": "middle aged",
"gender": "male",
"use case": "narration"
}
},
{
"voice_id": "LcfcDJNUP1GQjkzn1xUU",
"labels": {
"accent": "american",
"description": "calm",
"age": "young",
"gender": "female",
"use case": "meditation"
}
},
{
"voice_id": "MF3mGyEYCl7XYWbV9V6O",
"labels": {
"accent": "american",
"description": "emotional",
"age": "young",
"gender": "female",
"use case": "narration"
}
},
{
"voice_id": "N2lVS1w4EtoT3dr4eOWO",
"labels": {
"accent": "american",
"description": "hoarse",
"age": "middle aged",
"gender": "male",
"use case": "video games"
}
},
{
"voice_id": "ODq5zmih8GrVes37Dizd",
"labels": {
"accent": "american",
"description": "shouty",
"age": "middle aged",
"gender": "male",
"use case": "video games"
}
},
{
"voice_id": "SOYHLrjzK2X1ezoPC6cr",
"labels": {
"accent": "american",
"description": "anxious",
"age": "young",
"gender": "male",
"use case": "video games"
}
},
{
"voice_id": "TX3LPaxmHKxFdv7VOQHJ",
"labels": {
"accent": "american",
"age": "young",
"gender": "male",
"use case": "narration",
"description ": "neutral"
}
},
{
"voice_id": "ThT5KcBeYPX3keUQqHPh",
"labels": {
"accent": "british",
"description": "pleasant",
"age": "young",
"gender": "female",
"use case": "children's stories"
}
},
{
"voice_id": "TxGEqnHWrfWFTfGW9XjX",
"labels": {
"accent": "american",
"description": "deep",
"age": "young",
"gender": "male",
"use case": "narration"
}
},
{
"voice_id": "VR6AewLTigWG4xSOukaG",
"labels": {
"accent": "american",
"description": "crisp",
"age": "middle aged",
"gender": "male",
"use case": "narration"
}
},
{
"voice_id": "XB0fDUnXU5powFXDhCwa",
"labels": {
"accent": "english-swedish",
"description": "seductive",
"age": "middle aged",
"gender": "female",
"use case": "video games"
}
},
{
"voice_id": "Xb7hH8MSUJpSbSDYk0k2",
"labels": {
"accent": "british",
"description": "confident",
"age": "middle aged",
"gender": "female",
"featured": "new",
"use case": "news"
}
},
{
"voice_id": "XrExE9yKIg1WjnnlVkGX",
"labels": {
"accent": "american",
"description": "warm",
"age": "young",
"gender": "female",
"use case": "audiobook"
}
},
{
"voice_id": "ZQe5CZNOzWyzPSCn5a3c",
"labels": {
"accent": "australian",
"description": "calm ",
"age": "old",
"gender": "male",
"use case": "news"
}
},
{
"voice_id": "Zlb1dXrM653N07WRdFW3",
"labels": {
"accent": "british",
"age": "middle aged",
"gender": "male",
"use case": "news",
"description ": "ground reporter "
}
},
{
"voice_id": "bVMeCyTHy58xNoL34h3p",
"labels": {
"accent": "american-irish",
"description": "excited",
"age": "young",
"gender": "male",
"use case": "narration"
}
},
{
"voice_id": "flq6f7yk4E4fJM5XTYuZ",
"labels": {
"accent": "american",
"age": "old",
"gender": "male",
"use case": "audiobook",
"description ": "orotund"
}
},
{
"voice_id": "g5CIjZEefAph4nQFvHAz",
"labels": {
"accent": "american",
"age": "young",
"gender": "male",
"use case": "ASMR",
"description ": "whisper"
}
},
{
"voice_id": "iP95p4xoKVk53GoZ742B",
"labels": {
"accent": "american",
"description": "casual",
"age": "middle aged",
"gender": "male",
"featured": "new",
"use case": "conversational"
}
},
{
"voice_id": "jBpfuIE2acCO8z3wKNLl",
"labels": {
"accent": "american",
"description": "childlish",
"age": "young",
"gender": "female",
"use case": "animation"
}
},
{
"voice_id": "jsCqWAovK2LkecY7zXl4",
"labels": {
"accent": "american",
"age": "young",
"gender": "female",
"description ": "overhyped",
"usecase": "video games"
}
},
{
"voice_id": "nPczCjzI2devNBz1zQrb",
"labels": {
"accent": "american",
"description": "deep",
"age": "middle aged",
"gender": "male",
"featured": "new",
"use case": "narration"
}
},
{
"voice_id": "oWAxZDx7w5VEj9dCyTzz",
"labels": {
"accent": "american-southern",
"age": "young",
"gender": "female",
"use case": "audiobook ",
"description ": "gentle"
}
},
{
"voice_id": "onwK4e9ZLuTAKqWW03F9",
"labels": {
"accent": "british",
"description": "deep",
"age": "middle aged",
"gender": "male",
"use case": "news presenter"
}
},
{
"voice_id": "pFZP5JQG7iQjIQuC4Bku",
"labels": {
"accent": "british",
"description": "raspy",
"age": "middle aged",
"gender": "female",
"use case": "narration"
}
},
{
"voice_id": "pMsXgVXv3BLzUgSXRplE",
"labels": {
"accent": "american",
"description": "pleasant",
"age": "middle aged",
"gender": "female",
"use case": "interactive"
}
},
{
"voice_id": "pNInz6obpgDQGcFmaJgB",
"labels": {
"accent": "american",
"description": "deep",
"age": "middle aged",
"gender": "male",
"use case": "narration"
}
},
{
"voice_id": "piTKgcLEGmPE4e6mEKli",
"labels": {
"accent": "american",
"description": "whisper",
"age": "young",
"gender": "female",
"use case": "audiobook"
}
},
{
"voice_id": "pqHfZKP75CvOlQylNhV4",
"labels": {
"accent": "american",
"description": "strong",
"age": "middle aged",
"gender": "male",
"use case": "documentary"
}
},
{
"voice_id": "t0jbNlBVZ17f02VDIeMI",
"labels": {
"accent": "american",
"description": "raspy ",
"age": "old",
"gender": "male",
"use case": "video games"
}
},
{
"voice_id": "yoZ06aMxZJJ28mfd3POQ",
"labels": {
"accent": "american",
"description": "raspy",
"age": "young",
"gender": "male",
"use case": "narration"
}
},
{
"voice_id": "z9fAnlkpzviPz146aGWa",
"labels": {
"accent": "american",
"description": "witch",
"age": "middle aged",
"gender": "female",
"use case": "video games"
}
},
{
"voice_id": "zcAOhNBS3c14rBihAFp1",
"labels": {
"accent": "english-italian",
"description": "foreigner",
"age": "young",
"gender": "male",
"use case": "audiobook"
}
},
{
"voice_id": "zrHiDhphv9ZnVXBqCLjz",
"labels": {
"accent": "english-swedish",
"description": "childish",
"age": "young",
"gender": "female",
"use case": "animation"
}
}
]
}
```
### text\_to\_speech Function
The `text_to_speech` function converts the provided text into speech using the specified Voice ID and streams it to the user via WebSocket.
```python theme={"system"}
async def text_to_speech(self, text: str, voice_id: str):
```
### Parameters
* `text (str)`: The text to be converted into speech.
* `voice_id (str)`: The Voice ID to be used for speech synthesis.
***
## Exiting an Ability
An Ability returns control to the Agent when it finishes its work by calling `resume_normal_flow()`. Beyond that normal completion, there are two ways to leave a Skill Ability on demand: the built-in exit phrases the SDK recognizes, and exit handling you add in your own code.
### Built-in exit phrases
While a Skill Ability is active, the SDK recognizes a fixed set of spoken exit phrases. Speaking any of them ends the current Ability and returns control to the Agent's conversation flow. This is the same outcome as calling `resume_normal_flow()`, but initiated by the user rather than by the Ability. This is built in and requires no additional code, so a user always has a way out of an Ability, including one that is mid-conversation or waiting for input.
The recognized exit phrases are:
* *"open home exit"*
* *"openhome exit"*
* *"open home quit"*
* *"openhome quit"*
* *"open home, quit"*
* *"open home, exit"*
* *"exit open home"*
* *"quit open home"*
* *"exit, open home"*
* *"quit, open home"*
> **Note:** Built-in exit phrases apply to Skill Abilities.
### Handling exit in your own code
You can also end an Ability from your own code. Detect an exit condition in the user's input, such as your own stop words, and call `resume_normal_flow()` to return control to the Agent.
```python theme={"system"}
async def run(self):
while True:
user_input = await self.capability_worker.user_response()
if user_input and user_input.strip().lower() in ("stop", "cancel", "done"):
await self.capability_worker.speak("Okay, done.")
self.capability_worker.resume_normal_flow()
return
# ... handle the request ...
```
## Advanced CapabilityWorker Functions
### Audio Processing Functions
The `CapabilityWorker` provides comprehensive audio handling capabilities:
* **`play_audio`**: Play audio content directly or file objects
* **`play_from_audio_file`**: Play audio files stored in the capability directory
* **`send_audio_data_in_stream`**: Stream processed audio data over WebSocket
### Text Generation Functions
Multiple options for text generation:
* **`text_to_text_response`**: Standard text generation with history and system prompts
* **`generate_ttt_using_openrouter`**: Alternate text generation using OpenRouter
* **`llm_search`**: Web-search-backed short answer
* **`llm_tools`**: Tool-calling with the model
### Streaming and Communication
Advanced communication features:
* **`stream_init`** and **`stream_end`**: Manage audio streaming sessions
* **`stream_music_from_url`**: Play a progressive mp3 link, blocking until playback genuinely ends, and report why it ended. See [Music Playback](#music-playback) for the full reference and [Example 4](#example-4-music-playback) for a complete Ability.
* **`send_data_over_websocket`**: Send custom data over WebSocket
* **`send_interrupt_signal`**: Interrupt ongoing output and hand control back to user input
* **`send_agent_message_without_audio`**: Send a text reply without TTS
* **`send_devkit_action`**: Trigger a Devkit action
* **`send_devkit_mqtt_action`**: Trigger a DevKit MQTT action to control a smart device. See [Controlling MQTT Devices](/building-abilities/mqtt-device-control) for the full reference and an example Ability.
### Context and Session Helpers
* **`get_timezone`**: Read the current user's timezone for local-time-aware behavior
* **`get_token`**: Read the access token of a user-linked provider account (`"google"`, `"slack"`, `"discord"`, `"microsoft"`, `"tesla"`). See [Reading Linked Account Tokens with `get_token()`](#reading-linked-account-tokens-with-get-token) for the full reference and a `run()` example with a fallback message when the account isn't connected.
* **`get_api_keys`**: Read custom API key values from **Settings → API Keys** by key name
* **`get_full_message_history`**: Read full session message history for context-aware responses
* **`update_personality_agent_prompt`**: Append context/instructions to the Agent personality prompt
* **`create_key` / `update_key` / `delete_key`**: Manage structured key-value context storage
* **`get_single_key` / `get_all_keys`**: Read one or all stored context entries
### Recording and Local Audio
* **`get_audio_recording`**: Load the latest user recording bytes
* **`get_audio_recording_length`**: Duration in seconds for the latest recording
* **`flush_audio_recording`**: Clear the current recording before a new capture
* **`play_from_audio_file`**: Play an audio file stored in the Ability directory
***
### Session Task Utilities (replace raw asyncio usage)
To ensure Abilities run within the agent's managed lifecycle, avoid using raw `asyncio` helpers directly.
* Use `self.worker.session_tasks.sleep(seconds: float)` instead of `asyncio.sleep(...)`:
```python theme={"system"}
async def some_task(self):
await self.worker.session_tasks.sleep(1.5)
await self.capability_worker.speak("Thanks for waiting!")
```
These helpers ensure proper cancellation, cleanup, and session scoping.
### Background Daemon Entry Point (`background.py`)
Background daemons run automatically when a session starts. Use a separate `background.py` file with this entry signature:
```python theme={"system"}
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())
```
Daemon rules:
* Keep daemon logic inside a continuous `while True` loop.
* Use `await self.worker.session_tasks.sleep(...)` between cycles.
* Do not call `resume_normal_flow()` inside daemon loops.
* Call `await self.capability_worker.send_interrupt_signal()` before daemon speech/audio.
## Example 1: Basic Capability
This Ability creates a daily life advisor that:
1. **Asks the user for a problem**: Initiates a conversation to gather user input.
2. **Provides advice**: Offers a solution based on user input.
3. **Collects feedback**: Captures user satisfaction with the advice.
***
### Code
```python theme={"system"}
import json
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
class BasicCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
# Do not change following tag of register capability
#{{register capability}}
async def give_advice(self):
await self.capability_worker.speak("Hi! I'm your daily life advisor. Tell me your problem.")
user_problem = await self.capability_worker.user_response()
solution_prompt = f"Provide a solution for: {user_problem}"
solution = self.capability_worker.text_to_text_response(solution_prompt)
user_feedback = await self.capability_worker.run_io_loop(
solution + " Are you satisfied with the advice?"
)
await self.capability_worker.speak("Thank you for using the advisor.")
self.capability_worker.resume_normal_flow()
def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self)
self.worker.session_tasks.create(self.give_advice())
```
### Key Functions
* **`speak`**: Introduces the advisor and provides the solution.
* **`user_response`**: Captures user input (e.g., their problem).
* **`run_io_loop`**: Combines speaking the solution and listening for feedback.
* **`resume_normal_flow`**: Resumes the agent's default workflow after interaction.
***
## Example 2: Weather Capability
This Ability integrates a weather API to fetch and share weather updates based on user-provided locations.
***
### Code
```python theme={"system"}
import json
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
class WeatherDocsCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
# Do not change following tag of register capability
#{{register capability}}
async def first_setup(self, location: str):
if not location:
await self.capability_worker.speak("Which location?")
location = await self.capability_worker.user_response()
geolocator = Nominatim(user_agent="weather_agent")
loc = geolocator.geocode(location)
if loc:
result = self.worker.session_tasks.get(
f"https://api.open-meteo.com/v1/forecast?latitude={loc.latitude}&longitude={loc.longitude}¤t=temperature_2m"
).json()
weather_report = f"The temperature in {location} is {result['current']['temperature_2m']}°C."
await self.capability_worker.speak(weather_report)
else:
await self.capability_worker.speak("Invalid location. Try again.")
self.capability_worker.resume_normal_flow()
def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self)
self.worker.session_tasks.create(self.first_setup(""))
```
### Key Features
* **External API Call**: Fetches real-time weather data.
* **Geolocation**: Validates and processes user-provided locations.
* **Error Handling**: Provides meaningful feedback for invalid inputs.
***
## Allowed/Disallowed Libraries and Patterns
The following imports, keywords, and patterns are not allowed in Abilities. Use the safe alternatives.
### Blocked Imports and Keywords
| Name | Why not allowed |
| ------------ | ------------------------------------------------------------------------------------------------------ |
| redis | Direct datastore coupling and security concerns; not portable across deployments. |
| user\_config | Raw config access can leak or mutate global state; use provided APIs on `CapabilityWorker`/`worker`. |
| print | Bypasses structured logging; noisy and untraceable in production; use `editor_logging_handler`. |
| open (raw) | Unmanaged filesystem access; security and portability risks; prefer approved helpers/per-user storage. |
Guidance:
* Avoid direct storage/infra access. Use platform-provided helpers within `CapabilityWorker`/`worker` or request an API if needed.
* Use the provided logging (`editor_logging_handler`) instead of prints.
* For files, prefer platform abstractions and per-user capability folders; ask for an approved helper if you need persistent storage.
* For HTTP GET requests, always use the `self.worker.session_tasks` helpers (`get`, `get_async`, `httpx_get`, `httpx_get_async`, `aiohttp_get_async`). Never call `requests`, `httpx`, or `aiohttp` directly. See [Making HTTP Requests](#making-http-requests).
### Security Guidance
Avoid insecure or unsafe patterns such as runtime `assert` checks, `exec()` of dynamic code, binding servers to all interfaces, hardcoded secrets, swallowing exceptions, insecure deserialization (pickle/dill/shelve/marshal), weak hashes like MD5, or weak cipher modes (e.g., ECB). If you have a special case, request approval and an approved wrapper/utility.
***
## Conclusion
Building Abilities in OpenHome empowers developers to create custom functionalities for AI agents. With the examples like the Basic Advisor and Weather Capability, you can:
* **Core Communication**: Use `speak`, `run_io_loop`, and `user_response` for basic interactions.
* **Advanced Audio**: Play custom audio files, and stream audio data.
* **Text Generation**: Leverage multiple text-to-text options with history and system prompts.
* **Voice Customization**: Use specific voice IDs for varied and engaging responses.
* **External APIs**: Integrate third-party services for dynamic functionality.
The examples demonstrate everything from basic conversational flows to advanced audio processing and device control. The `CapabilityWorker` provides all the tools needed to create sophisticated, interactive Abilities.
> Start creating innovative Abilities and push the boundaries of voice AI with OpenHome! 🎉
> **Note:** For GET requests to third-party APIs, use `self.worker.session_tasks.get()` (see [Making HTTP Requests](#making-http-requests)). It takes the same arguments as the `requests` module's `get()` and returns the same response. For other request types, use the `requests` module and avoid other libraries; if you need a library that isn't available, you can request that we add it.
***
## Example 3: Read/Write File (from `example_main.py`)
This is the simplest pattern for per-user storage.
```python theme={"system"}
class ReadwriteFileCapability(MatchingCapability):
async def perform_action(self):
user_response = await self.capability_worker.wait_for_complete_transcription()
await self.capability_worker.speak("Writing last transcription to file.")
if await self.capability_worker.check_if_file_exists(
"temp_data.txt",
in_ability_directory=False,
):
await self.capability_worker.write_file(
"temp_data.txt",
"\n%s: %s" % (time(), user_response),
in_ability_directory=False,
)
else:
await self.capability_worker.write_file(
"temp_data.txt",
"%s: %s" % (time(), user_response),
in_ability_directory=False,
)
file_data = await self.capability_worker.read_file(
"temp_data.txt",
in_ability_directory=False,
)
last_written_line = file_data.split("\n")[-1].split(":")[1]
await self.capability_worker.speak("Last Written Line: %s" % last_written_line)
self.capability_worker.resume_normal_flow()
```
***
## Example 4: Music Playback
`stream_music_from_url()` plays a track and blocks until playback genuinely ends, then reports why in `result["outcome"]`. Only `"paused"` continues the loop, and a resume is the same call again with the same `url`. See the [Music Playback](#music-playback) reference above for the full parameter and `outcome` tables.
```python theme={"system"}
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
# Replace with your music service.
MUSIC_API = "https://api.example.com"
class MusicDocsCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
api_key: str = None
# Do not change following tag of register capability
#{{register capability}}
async def play_music(self, request: str):
if not request:
await self.capability_worker.speak("What would you like to hear?")
request = await self.capability_worker.user_response()
track = self.search_track(request)
if track:
# Resolved once, not per pass: handing the SAME url back is what
# resumes a paused track. Re-resolving mints a new url, which reads
# as a new track and restarts it from the top.
url = self.stream_url(track)
announced = False
while True:
result = await self.capability_worker.stream_music_from_url(
url,
# Positional, so it cannot be forgotten. Pass "" for a host
# that wants no Authorization header. Do not assume a link
# from an authenticated API is self-signed -- some CDNs
# answer 401 without the token, and every track then comes
# back "unplayable".
f"Bearer {self.api_key}",
announce=f"Playing {track['title']}." if not announced else "",
)
announced = True
# Only a pause continues the loop. finished, stopped, error and
# unplayable all leave.
if result["outcome"] != "paused":
break
reply = await self.capability_worker.run_io_loop(
"Paused. Say resume or stop."
)
# An unclear reply stops. A user who asked for silence must not
# get the track back because a reply didn't parse.
if "resume" not in (reply or "").lower():
break
await self.capability_worker.speak("Okay, that's it for the music.")
else:
await self.capability_worker.speak("I couldn't find that one. Try again.")
self.capability_worker.resume_normal_flow()
def search_track(self, request: str):
result = self.worker.session_tasks.get(
f"{MUSIC_API}/search?q={request}&limit=1"
).json()
tracks = result.get("tracks") or []
if not tracks:
return None
track = tracks[0]
# No duration: the engine reads the byte rate off the mp3 frame header,
# so a track with missing or wrong metadata still plays and resumes.
return {
"id": track["id"],
"title": track["title"],
"artist": track.get("artist", ""),
}
def stream_url(self, track):
# Kept separate from search_track() so a link can be re-resolved without
# searching again -- though the loop above resolves it only once.
result = self.worker.session_tasks.get(
f"{MUSIC_API}/tracks/{track['id']}/stream"
).json()
return result["mp3_url"] # progressive mp3
def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self)
self.api_key = self.capability_worker.get_api_keys("music_api_key")
self.worker.session_tasks.create(self.play_music(""))
```
### Key Features
* **One `await` covers the whole track.** The audio pipeline, the device buffer, music mode and the recovery afterwards are all inside the call, so `speak()` works on the very next line no matter how playback ended.
* **No duration, no position, no byte offset.** The engine reads the byte rate off the mp3's own frame header, so a track with missing or wrong metadata still plays and resumes correctly.
* **The URL is resolved once per track**, outside the loop: handing the same url back is what resumes a pause, so a fresh url would restart the track instead.
* **`auth` is positional**, so it cannot be forgotten. Pass `""` for a host that wants no `Authorization` header.
* **An unclear pause reply stops.** A user who asked for silence must not get the track back because a reply didn't parse.
While a stream is live the Ability is in music mode: it receives no transcriptions, and must not call `speak()` or `run_io_loop()`. Speak before the call or after it returns.
# Local Abilities
Source: https://docs.openhome.com/building-abilities/local-ability
A special Ability type that runs on the OpenHome DevKit and can use hardware, connected peripherals, the file system, shell commands, and the device's Python environment.
Local Abilities are a specialized Ability type for running DevKit-side code from an OpenHome Ability. Unlike other Ability types, which operate only within the standard Ability runtime, Local Abilities can use the DevKit hardware, system resources, and the Python environment installed on the device.
This includes Python imports that are restricted in the standard runtime, file system operations, shell commands, hardware access such as GPIO pins, sensors, LEDs, and connected peripherals, and system-level data such as CPU, memory, temperature, and network state.
Use Local Abilities for IoT projects, custom hardware integrations, voice-controlled physical devices, device telemetry, long-running on-device tasks, and any use case that requires direct interaction with the DevKit or capabilities beyond the standard Ability runtime.
Local Abilities only run on actual OpenHome DevKit hardware. They do not run in the web Live Editor's simulated environment.
## How It Works
A Local Ability is split between the standard Ability runtime and DevKit-side execution. `main.py` handles the Agent flow, while `devkit_functions.py` runs hardware, system, and device-level code on the OpenHome DevKit.
### File Structure
| File | Runtime | Use for |
| --------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `main.py` | Standard Ability runtime | Voice interaction, prompts, conversation state, SDK calls, and calls to DevKit-side functions. |
| `devkit_functions.py` | OpenHome DevKit | Hardware control, connected peripherals, system operations, shell commands, system telemetry, ambient intelligence workflows, and DevKit-side Python packages. |
| `requirements.txt` | OpenHome DevKit | Python dependencies installed for `devkit_functions.py`. |
The DevKit-side file **must** be named exactly `devkit_functions.py`. No other filename will be picked up by the platform.
Packages listed in `requirements.txt` are installed for `devkit_functions.py` on the OpenHome DevKit. They are not available in the standard Ability runtime where `main.py` runs.
### Calling DevKit Functions
Use `send_devkit_capability_action()` in `main.py` to run a registered function from `devkit_functions.py`.
```python theme={"system"}
result = await self.capability_worker.send_devkit_capability_action(
function_name="your_function_name",
args=["arg1", "arg2"],
timeout=10,
)
```
| Parameter | Type | Description |
| ----------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `function_name` | `str` | Name of the function registered in `devkit_functions.py`. |
| `args` | `list[str]` | Arguments passed to the DevKit function. Values are passed as strings; cast them inside `devkit_functions.py` when another type is required. |
| `timeout` | `int` | Maximum number of seconds to wait for the function to complete. |
| `capability_name` | `str` *(optional)* | Name of another installed Ability whose `devkit_functions.py` should handle the call. Omit to use the current Ability. |
### `devkit_functions.py` Execution Flow
`devkit_functions.py` runs on the OpenHome DevKit as a Python script. Functions that should be callable from `main.py` must be registered in `FUNCTION_REGISTRY`, and the `function_name` passed from `main.py` must match one of those registry keys.
`devkit_functions.py` should include a Python main guard: `if __name__ == "__main__"`. The main guard reads the requested function name and arguments, then runs the matching registered function.
Values in `args` are passed to the DevKit-side function as strings. Cast them inside `devkit_functions.py` when the function requires a specific type, such as an integer, boolean, or JSON object.
Use `print()` for output that should be returned to `main.py`; standard output is captured in `result["output"]`. Python `return` values are not captured by `send_devkit_capability_action()`.
Use `web_logger` for diagnostics. These logs appear in the **DevKit** section of the Ability Live Editor and are not returned to `main.py`.
### Response Shape
`send_devkit_capability_action()` returns an object with the execution status, captured output, and request metadata.
```python theme={"system"}
{
"success": True, # True if the DevKit function completed successfully
"output": "captured stdout", # Output from print() calls in devkit_functions.py
"error": None, # Captured stderr or execution error details
"function_name": "function_name", # Function that was executed
"args": ["arg1", "arg2"], # Arguments passed to the function
"capability_name": "ability_name" # Ability that handled the request
}
```
`output` contains the standard output produced during execution. If the function does not print anything, `output` is `None`.
`error` contains the error message when execution fails. Otherwise, it is `None`.
Logs written with `web_logger` are separate from the returned object. They appear in the **DevKit** section of the Ability Live Editor logs and are useful for debugging DevKit-side execution.
### Example: Wi-Fi Status
This example reads the DevKit's current Wi-Fi connection and speaks it back to the user.
**`devkit_functions.py`** — runs on the DevKit:
```python theme={"system"}
import json
import sys
import subprocess
from devkit_utils.devkit_logging import web_logger as log
def _print_payload(payload):
output = json.dumps(payload)
log.info("stdout payload: %s", output)
print(output)
def check_wifi():
try:
result = subprocess.run(
["iwgetid", "-r"], capture_output=True, text=True, timeout=5
)
ssid = result.stdout.strip()
if ssid:
_print_payload({
"success": True,
"metric": "wifi",
"spoken_response": f"Wi-Fi is connected to {ssid}.",
"data": {"connected": True, "ssid": ssid},
"error": None,
})
else:
_print_payload({
"success": True,
"metric": "wifi",
"spoken_response": "Wi-Fi is not connected.",
"data": {"connected": False, "ssid": None},
"error": None,
})
except Exception as error:
log.exception("check_wifi failed")
_print_payload({
"success": False,
"metric": "wifi",
"spoken_response": "I couldn't read Wi-Fi status.",
"data": {},
"error": {
"code": "wifi_error",
"message": str(error),
},
})
FUNCTION_REGISTRY = {
"check_wifi": check_wifi,
}
if __name__ == "__main__":
function_name = sys.argv[1]
FUNCTION_REGISTRY[function_name](*sys.argv[2:])
```
**`main.py`** — runs in the standard Ability runtime:
```python theme={"system"}
import json
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
class WifiStatusCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
#{{register capability}}
async def first_function(self):
try:
result = await self.capability_worker.send_devkit_capability_action(
function_name="check_wifi",
args=[],
timeout=5,
)
await self.capability_worker.speak(self._spoken_response_from_result(result))
finally:
self.capability_worker.resume_normal_flow()
def _spoken_response_from_result(self, result):
if not isinstance(result, dict) or not result.get("success"):
return "I couldn't fetch Wi-Fi status from the DevKit."
output = (result.get("output") or "").strip()
if not output:
return "The DevKit did not return Wi-Fi status."
try:
payload = json.loads(output)
except json.JSONDecodeError:
return "I couldn't read the DevKit response."
return payload.get("spoken_response") or "I couldn't read Wi-Fi status."
def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self)
self.worker.session_tasks.create(self.first_function())
```
### Calling Functions from Another Local Ability
`main.py` can also call functions from another installed Local Ability's `devkit_functions.py`. This is useful when one Local Ability exposes reusable DevKit-side functions that another Ability needs to use.
`capability_name` is only needed for cross-Ability calls. When it is omitted, the call uses the current Ability's `devkit_functions.py`.
```python theme={"system"}
result = await self.capability_worker.send_devkit_capability_action(
function_name="get_sensor_value",
args=["temperature"],
timeout=10,
capability_name="target_ability_name",
)
```
To find the name of an installed Ability to use as `capability_name`, see [Installed Abilities](#installed-abilities) later in this document.
## Local Abilities in the Live Editor
### Select the Local Category
To create a Local Ability, select **Local** from the Ability categories and choose a template.
If you upload a custom Ability, the project must include `devkit_functions.py` and `requirements.txt`.
### Advanced DevKit Controls
If your DevKit is online and connected, the **Advanced DevKit Controls** toggle appears in the Ability Editor. Enable it to expand the Advanced DevKit Controls section.
Once Advanced DevKit Controls are enabled, scroll down and you will see the Advanced DevKit Controls section. From here you can sync your Ability to the DevKit, restart the Agent, and view the DevKit connection status.
### Sync Local Abilities with the DevKit
When the DevKit is online and connected, changes saved in the Live Editor are synced to the DevKit automatically.
On save:
* **`devkit_functions.py` or `requirements.txt`** changes are pushed to the DevKit without restarting the Agent. If `requirements.txt` changed, new dependencies are installed on the DevKit.
* **`main.py`** changes are saved to the OpenHome platform, synced with the DevKit sandbox, and the Agent restarts on the DevKit so the latest Ability code is used.
When editing `main.py`, save after completing the intended change. Each save can restart the Agent on the DevKit while the DevKit is connected.
If the DevKit was offline while you updated a Local Ability:
* **`main.py`** changes sync when the DevKit reconnects.
* **`devkit_functions.py` or `requirements.txt`** changes should be synced before testing. After the DevKit reconnects, click **Sync Abilities** from Advanced DevKit Controls to apply the latest changes.
You can also sync from **Advanced DevKit Controls** in the Live Editor, or from the **OpenHome - Voice AI Devkit App** dashboard using the **Sync Abilities** button .
### Logging on the DevKit
Use the DevKit logger inside `devkit_functions.py` to debug on-device behavior. Messages written with this logger appear in the **DevKit** section of the Ability Editor logs.
```python theme={"system"}
from devkit_utils.devkit_logging import web_logger as log
log.info("devkit stats functions loaded")
def check_temperature():
log.info("check_temperature: entry")
# Your DevKit-side code runs here
log.info("check_temperature: completed")
```
To view the logs, open the **DevKit** section inside the Ability Editor logs after triggering the Ability on the DevKit.
### Installed Abilities
To use functions from another Ability's `devkit_functions.py`, you need that Ability's name to pass in the `capability_name` parameter. To find it, click the **Quick Reference Installed Abilities** button in the top-left corner of the Ability Editor.
This opens the installed Local Abilities list. Copy the name of the Local Ability that contains the target `devkit_functions.py` file and pass it in the `capability_name` parameter.
## Example: DevKit Stats
This is a voice-controlled DevKit telemetry reporter. Users say something like *"check cpu"* or *"how hot is the devkit"* and the DevKit reads its system stats and speaks them back.
### Trigger words
This example can be triggered with phrases like:
* `devkit info`
* `system info`
* `how long has my devkit been running`
### `requirements.txt`
No third-party packages are required for this example — all stat checks use Python's standard library and standard Linux interfaces (`/proc`, `/sys`, and shell commands like `iwgetid`, `df`).
For other Local Abilities that need hardware libraries, list them here. Some common examples:
```
rpi-ws281x # NeoPixel / WS281x LED strip control
gpiozero # high-level GPIO pin control
RPi.GPIO # low-level GPIO access
picamera2 # camera access
adafruit-blinka # CircuitPython compatibility for sensors
smbus2 # I2C bus communication
pyserial # serial port communication
```
Only the packages you actually import in `devkit_functions.py` need to go here — they get installed on the DevKit side when you sync.
### `main.py` — standard Ability runtime
````python theme={"system"}
import json
import re
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
AVAILABLE_STATS = {
"get_cpu": "CPU usage",
"get_memory": "Memory usage",
"get_temperature": "Device temperature",
"get_uptime": "Device uptime",
"get_wifi": "Wi-Fi connection",
"get_disk": "Disk usage",
"get_health": "Overall device health",
"get_all_stats": "Summary of all key metrics",
}
FUNCTIONS_DESCRIPTION = "\n".join(
f"- {name}: {description}" for name, description in AVAILABLE_STATS.items()
)
SYSTEM_PROMPT = f"""You are a request router for a DevKit telemetry Ability. Your sole responsibility is to map user input to exactly one function name. You do not answer questions, explain concepts, or generate conversational responses.
## Device Context
The OpenHome DevKit is the user's locally connected device. Telemetry refers to its live runtime metrics: CPU, memory, temperature, uptime, Wi-Fi, disk, and health. This Ability is limited strictly to the functions listed below.
## Response Format
Always return a single JSON object. No prose, no markdown, no extra keys.
{{"function_name": ""}}
## Available Functions
{FUNCTIONS_DESCRIPTION}
## Routing Rules
- General status, "all stats", "everything", "snapshot", "system info" -> get_all_stats
- CPU, processor, load, compute, busy, usage -> get_cpu
- Memory, RAM, available memory, used memory -> get_memory
- Temperature, temp, heat, thermal, hot, warm -> get_temperature
- Uptime, boot time, running time, how long running -> get_uptime
- Wi-Fi, wifi, network, SSID, connection -> get_wifi
- Disk, storage, free space, used space -> get_disk
- Health, diagnostics, issues, problems, anything wrong -> get_health
## Exit Routing
Trigger `exit` when the user says: stop, quit, cancel, end, done, all done, that's all, thank you, thanks, goodbye, bye — or any close variation, even with filler words.
## Unsupported Requests
If the request is unrelated to DevKit telemetry, or asks for telemetry not covered by any available function, return:
{{"function_name": "none"}}
## Hard Rules
- Return exactly one function_name per response.
- Never explain, define, or discuss any concept — even if directly asked.
- Route by intent: if the user asks "what is my CPU usage?" that is a CPU telemetry request -> get_cpu.
- Do not include any text outside the JSON object.
"""
class DevKitStatsCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
#{{register capability}}
async def first_function(self):
try:
is_first_turn = True
conversation_history = []
while True:
if is_first_turn:
user_message = await self.capability_worker.wait_for_complete_transcription()
else:
user_message = await self.capability_worker.user_response()
if not user_message or not user_message.strip():
continue
route = self._route_to_devkit_function(user_message, conversation_history)
function_name = route.get("function_name", "")
if is_first_turn and function_name in ("", "none"):
function_name = "get_all_stats"
if function_name == "exit":
await self.capability_worker.speak("Exiting DevKit stats.")
break
if function_name not in AVAILABLE_STATS:
await self.capability_worker.speak(
"I can't fetch that DevKit information. Try asking for CPU, memory, temperature, disk, uptime, Wi-Fi, or health."
)
is_first_turn = False
continue
result = await self.capability_worker.send_devkit_capability_action(
function_name=function_name,
args=[],
timeout=8,
)
spoken_message = self._spoken_response_from_result(result)
await self.capability_worker.speak(spoken_message)
conversation_history.append({"role": "user", "content": user_message})
conversation_history.append({"role": "assistant", "content": spoken_message})
conversation_history = conversation_history[-12:]
await self.capability_worker.speak("Want me to check anything else, or say stop to exit.")
is_first_turn = False
except Exception as error:
self.worker.editor_logging_handler.error(f"DevKit stats failed: {error}")
await self.capability_worker.speak("Something went wrong while checking DevKit stats.")
finally:
self.capability_worker.resume_normal_flow()
def _route_to_devkit_function(self, user_message, conversation_history):
response = self.capability_worker.text_to_text_response(
f'User request: "{user_message}"',
conversation_history,
system_prompt=SYSTEM_PROMPT,
)
cleaned = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", response.strip())
try:
return json.loads(cleaned)
except (json.JSONDecodeError, TypeError, ValueError):
return {"function_name": ""}
def _spoken_response_from_result(self, result):
if not isinstance(result, dict):
return "I couldn't reach the DevKit."
if not result.get("success"):
self.worker.editor_logging_handler.error(
f"DevKit call failed: {result.get('error')}"
)
return "I couldn't fetch that DevKit information. Try asking for another stat."
output = (result.get("output") or "").strip()
if not output:
return "I couldn't fetch that DevKit information. Try asking for another stat."
try:
payload = json.loads(output)
except json.JSONDecodeError:
self.worker.editor_logging_handler.error(f"Invalid DevKit output: {output}")
return "I couldn't read the DevKit response."
if not payload.get("success"):
error = payload.get("error") or {}
self.worker.editor_logging_handler.warning(
f"DevKit stat unavailable: {error.get('code')} {error.get('message')}"
)
return payload.get("spoken_response") or "I couldn't read that DevKit stat."
def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self)
self.worker.session_tasks.create(self.first_function())
````
### `devkit_functions.py` — DevKit-side telemetry
```python theme={"system"}
import json
import shutil
import subprocess
import sys
import time
from devkit_utils.devkit_logging import web_logger as log
def _emit_success(metric, spoken, data=None):
payload = {
"success": True,
"metric": metric,
"spoken_response": spoken,
"data": data or {},
"error": None,
}
serialized_payload = json.dumps(payload)
log.info("stdout payload: %s", serialized_payload)
print(serialized_payload)
def _emit_error(metric, code, message, spoken):
log.error("%s failed [%s]: %s", metric, code, message)
payload = {
"success": False,
"metric": metric,
"spoken_response": spoken,
"data": {},
"error": {
"code": code,
"message": message,
},
}
serialized_payload = json.dumps(payload)
log.info("stdout payload: %s", serialized_payload)
print(serialized_payload)
def _read_text_file(path):
try:
with open(path, "r", encoding="utf-8") as file_handle:
return file_handle.read().strip()
except (FileNotFoundError, PermissionError, OSError) as error:
log.warning("Could not read %s: %s", path, error)
return ""
def _run_command(command, timeout=5):
try:
completed = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
log.warning("Command timed out: %s", command)
return ""
except OSError as error:
log.warning("Command failed: %s: %s", command, error)
return ""
if completed.returncode != 0:
log.warning("Command returned %s: %s", completed.returncode, command)
return ""
return completed.stdout.strip()
def _safe_int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
def _safe_float(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def _read_memory_kb(field_name):
meminfo = _read_text_file("/proc/meminfo")
for line in meminfo.splitlines():
if line.startswith(field_name):
value = line.split(":", 1)[1].strip().split()[0]
return _safe_int(value)
return None
def _read_cpu_sample():
stat = _read_text_file("/proc/stat")
for line in stat.splitlines():
if line.startswith("cpu "):
values = [_safe_int(value) or 0 for value in line.split()[1:]]
if len(values) < 4:
return None
idle = values[3] + (values[4] if len(values) > 4 else 0)
return {"idle": idle, "total": sum(values)}
return None
def _read_cpu_usage_percent(sample_seconds=0.4):
first = _read_cpu_sample()
time.sleep(sample_seconds)
second = _read_cpu_sample()
if not first or not second:
return None
total_delta = second["total"] - first["total"]
idle_delta = second["idle"] - first["idle"]
if total_delta <= 0:
return None
return round((1 - idle_delta / total_delta) * 100)
def _gb_from_kb(value):
if value is None:
return None
return round(value / 1024 / 1024, 1)
def _temperature_status(celsius):
if celsius < 50:
return "running cool"
if celsius < 65:
return "comfortable"
if celsius < 75:
return "warm"
if celsius < 85:
return "hot"
return "very hot"
def get_cpu():
metric = "cpu"
log.info("get_cpu called")
try:
used_percent = _read_cpu_usage_percent()
if used_percent is None:
_emit_error(metric, "cpu_unavailable", "CPU usage could not be read.", "I couldn't read CPU usage.")
return
free_percent = 100 - used_percent
_emit_success(
metric,
f"CPU is {used_percent} percent used and {free_percent} percent free.",
{"used_percent": used_percent, "free_percent": free_percent},
)
except Exception as error:
log.exception("Unhandled error in get_cpu")
_emit_error(metric, "cpu_error", str(error), "I couldn't read CPU usage.")
def get_memory():
metric = "memory"
log.info("get_memory called")
try:
total_gb = _gb_from_kb(_read_memory_kb("MemTotal:"))
available_gb = _gb_from_kb(_read_memory_kb("MemAvailable:"))
if total_gb is None or available_gb is None:
_emit_error(metric, "memory_unavailable", "Memory info could not be read.", "I couldn't read memory usage.")
return
used_gb = round(total_gb - available_gb, 1)
_emit_success(
metric,
f"Memory has {used_gb} gigabytes used out of {total_gb}, with {available_gb} gigabytes available.",
{"total_gb": total_gb, "used_gb": used_gb, "available_gb": available_gb},
)
except Exception as error:
log.exception("Unhandled error in get_memory")
_emit_error(metric, "memory_error", str(error), "I couldn't read memory usage.")
def get_temperature():
metric = "temperature"
log.info("get_temperature called")
try:
raw_value = _read_text_file("/sys/class/thermal/thermal_zone0/temp")
millicelsius = _safe_int(raw_value)
if millicelsius is None:
_emit_error(metric, "temperature_unavailable", "Temperature value could not be read.", "I couldn't read the DevKit temperature.")
return
celsius = round(millicelsius / 1000, 1)
status = _temperature_status(celsius)
_emit_success(
metric,
f"DevKit temperature is {celsius} degrees Celsius and {status}.",
{"celsius": celsius, "status": status},
)
except Exception as error:
log.exception("Unhandled error in get_temperature")
_emit_error(metric, "temperature_error", str(error), "I couldn't read the DevKit temperature.")
def get_uptime():
metric = "uptime"
log.info("get_uptime called")
try:
uptime_text = _read_text_file("/proc/uptime")
uptime_seconds = _safe_float(uptime_text.split()[0]) if uptime_text else None
if uptime_seconds is None:
_emit_error(metric, "uptime_unavailable", "Uptime could not be read.", "I couldn't read DevKit uptime.")
return
days = int(uptime_seconds // 86400)
hours = int((uptime_seconds % 86400) // 3600)
minutes = int((uptime_seconds % 3600) // 60)
if days:
spoken_duration = f"{days} days and {hours} hours"
elif hours:
spoken_duration = f"{hours} hours and {minutes} minutes"
else:
spoken_duration = f"{minutes} minutes"
_emit_success(
metric,
f"The DevKit has been running for {spoken_duration}.",
{"seconds": round(uptime_seconds), "days": days, "hours": hours, "minutes": minutes},
)
except Exception as error:
log.exception("Unhandled error in get_uptime")
_emit_error(metric, "uptime_error", str(error), "I couldn't read DevKit uptime.")
def get_wifi():
metric = "wifi"
log.info("get_wifi called")
try:
ssid = _run_command("iwgetid -r 2>/dev/null")
if not ssid:
_emit_success(metric, "Wi-Fi is not connected.", {"connected": False, "ssid": None})
return
_emit_success(metric, f"Wi-Fi is connected to {ssid}.", {"connected": True, "ssid": ssid})
except Exception as error:
log.exception("Unhandled error in get_wifi")
_emit_error(metric, "wifi_error", str(error), "I couldn't read Wi-Fi status.")
def get_disk():
metric = "disk"
log.info("get_disk called")
try:
total_bytes, used_bytes, free_bytes = shutil.disk_usage("/")
total_gb = round(total_bytes / 1_000_000_000, 1)
used_gb = round(used_bytes / 1_000_000_000, 1)
free_gb = round(free_bytes / 1_000_000_000, 1)
used_percent = round((used_bytes / total_bytes) * 100)
_emit_success(
metric,
f"Disk is {used_percent} percent used, with {free_gb} gigabytes free.",
{
"total_gb": total_gb,
"used_gb": used_gb,
"free_gb": free_gb,
"used_percent": used_percent,
},
)
except Exception as error:
log.exception("Unhandled error in get_disk")
_emit_error(metric, "disk_error", str(error), "I couldn't read disk usage.")
def get_health():
metric = "health"
log.info("get_health called")
try:
issues = []
data = {}
raw_temperature = _safe_int(_read_text_file("/sys/class/thermal/thermal_zone0/temp"))
if raw_temperature is not None:
celsius = round(raw_temperature / 1000, 1)
data["temperature_celsius"] = celsius
if celsius >= 75:
issues.append(f"temperature is high at {celsius} degrees Celsius")
available_kb = _read_memory_kb("MemAvailable:")
if available_kb is not None:
available_mb = round(available_kb / 1024)
data["memory_available_mb"] = available_mb
if available_mb < 200:
issues.append(f"memory is low with {available_mb} megabytes available")
disk_total, disk_used, _ = shutil.disk_usage("/")
disk_used_percent = round((disk_used / disk_total) * 100)
data["disk_used_percent"] = disk_used_percent
if disk_used_percent >= 90:
issues.append(f"disk usage is high at {disk_used_percent} percent")
data["issues"] = issues
if not issues:
_emit_success(metric, "The DevKit looks healthy.", data)
elif len(issues) == 1:
_emit_success(metric, f"I found one issue: {issues[0]}.", data)
else:
_emit_success(metric, f"I found {len(issues)} issues: {', '.join(issues[:2])}.", data)
except Exception as error:
log.exception("Unhandled error in get_health")
_emit_error(metric, "health_error", str(error), "I couldn't run the DevKit health check.")
def get_all_stats():
metric = "all_stats"
log.info("get_all_stats called")
try:
cpu_percent = _read_cpu_usage_percent()
raw_temperature = _safe_int(_read_text_file("/sys/class/thermal/thermal_zone0/temp"))
temperature_celsius = round(raw_temperature / 1000, 1) if raw_temperature is not None else None
total_memory_gb = _gb_from_kb(_read_memory_kb("MemTotal:"))
available_memory_gb = _gb_from_kb(_read_memory_kb("MemAvailable:"))
ssid = _run_command("iwgetid -r 2>/dev/null")
total_bytes, used_bytes, free_bytes = shutil.disk_usage("/")
free_disk_gb = round(free_bytes / 1_000_000_000, 1)
disk_used_percent = round((used_bytes / total_bytes) * 100)
data = {
"cpu_used_percent": cpu_percent,
"temperature_celsius": temperature_celsius,
"memory_total_gb": total_memory_gb,
"memory_available_gb": available_memory_gb,
"wifi_connected": bool(ssid),
"wifi_ssid": ssid or None,
"disk_free_gb": free_disk_gb,
"disk_used_percent": disk_used_percent,
}
spoken_parts = []
if temperature_celsius is not None:
spoken_parts.append(f"temperature is {temperature_celsius} degrees Celsius")
if cpu_percent is not None:
spoken_parts.append(f"CPU is {cpu_percent} percent used")
if available_memory_gb is not None and total_memory_gb is not None:
spoken_parts.append(f"memory has {available_memory_gb} gigabytes available")
spoken_parts.append(f"disk is {disk_used_percent} percent used")
spoken_parts.append(f"Wi-Fi is connected to {ssid}" if ssid else "Wi-Fi is not connected")
_emit_success(metric, "DevKit snapshot: " + ", ".join(spoken_parts) + ".", data)
except Exception as error:
log.exception("Unhandled error in get_all_stats")
_emit_error(metric, "all_stats_error", str(error), "I couldn't gather the DevKit snapshot.")
FUNCTION_REGISTRY = {
"get_cpu": get_cpu,
"get_memory": get_memory,
"get_temperature": get_temperature,
"get_uptime": get_uptime,
"get_wifi": get_wifi,
"get_disk": get_disk,
"get_health": get_health,
"get_all_stats": get_all_stats,
}
def main():
if len(sys.argv) < 2:
_emit_error("dispatch", "missing_function", "No function name was provided.", "No DevKit function was provided.")
sys.exit(1)
function_name = sys.argv[1]
function_args = sys.argv[2:]
function = FUNCTION_REGISTRY.get(function_name)
if function is None:
_emit_error(
"dispatch",
"unknown_function",
f"Unknown function: {function_name}",
"The requested DevKit function is not available.",
)
sys.exit(1)
try:
function(*function_args)
except TypeError as error:
log.exception("Invalid arguments for %s", function_name)
_emit_error(
function_name,
"invalid_arguments",
str(error),
"The DevKit function received invalid arguments.",
)
sys.exit(1)
except Exception as error:
log.exception("Unhandled error while running %s", function_name)
_emit_error(
function_name,
"unhandled_error",
str(error),
"The DevKit function failed unexpectedly.",
)
sys.exit(1)
if __name__ == "__main__":
main()
```
## Interaction Flow
The user starts the Ability with a trigger phrase such as *"devkit stats"* or *"check cpu"*.
`main.py` keeps the voice flow in the standard Ability runtime and uses the LLM as a strict router from natural language to a registered DevKit telemetry function.
`main.py` calls `send_devkit_capability_action()` with the selected function name, arguments, and timeout. The matching function runs on the OpenHome DevKit from `devkit_functions.py`.
`devkit_functions.py` reads the requested device data, logs diagnostics with `web_logger`, and prints a structured JSON payload. The printed payload is captured in `result["output"]`.
`main.py` parses `result["output"]`, reads `spoken_response`, and speaks the result. The structured `data` field remains available for richer logic.
The Ability prompts for another stat or exits cleanly. On exit, `main.py` calls `resume_normal_flow()` so the Agent returns to its normal flow.
## Best practices
Clean separation makes both sides easier to debug. Keep `devkit_functions.py` focused on the hardware work.
Hardware calls can block. A 5–10 second timeout is typical for lightweight actions; bump to 30 or more for long-running effects or captures.
Use the DevKit logger `web_logger` for debugging and inspect messages in the **DevKit** logs section inside the Ability Editor.
Packages listed there are installed for `devkit_functions.py`. They are not available in the sandboxed runtime where `main.py` runs.
Not every DevKit has every peripheral. Wrap hardware initialization in `try/except` and log an informative error instead of crashing — your Ability can still speak a helpful message to the user.
## See also
* [Ability Types](/ability-types) — when Local is the right choice vs. Skill, Agent Controlled, or Background Daemon
* [Background Abilities](/building-abilities/background-abilities) — for always-on monitoring that doesn't need hardware access
* [SDK Reference](/api-sdk/sdk-reference) — full method catalog
* [Voice-First Best Practices](/guides/best-practices/voice-first) — the UX rules that apply to any Ability, including Local
# Local Connect
Source: https://docs.openhome.com/building-abilities/local-connect
Run terminal commands on your computer through OpenHome voice — cross-platform, minimal setup.
Local Connect is a lightweight alternative to [OpenClaw](/building-abilities/openclaw) — a single Python script you run on your computer that executes voice-generated terminal commands. Works on Windows, macOS, and Linux.
## What you can build
* System monitor (disk space, CPU, memory)
* File management assistant (create, move, delete files)
* Development environment controller (git, npm, dev servers)
* Application launcher
* Network diagnostics tool (ping, traceroute, speed test)
* Automation scripts (backups, cleanup)
## Setup
### 1. Check Python
```bash theme={"system"}
python3 --version
```
You need Python 3.7 or later.
### 2. Download the client
[Download `local_client.py`](https://drive.google.com/file/d/12Is4eXchH5dDjlG39Knp4oRuD-V3D-v_/view?usp=drive_link). Save anywhere convenient — `~/openhome/` on macOS/Linux, `C:\openhome\` on Windows.
### 3. Add your API key
Open `local_client.py` in a text editor. Find the line:
```python theme={"system"}
OPENHOME_API_KEY = "your_api_key_here"
```
Replace it with your [OpenHome API key](https://app.openhome.com/dashboard/settings).
### 4. Run the client
```bash theme={"system"}
python3 local_client.py
```
You should see `Connected to OpenHome`. Keep this terminal window open while using the Ability.
**Keep it alive across sessions:**
* **macOS/Linux** — use `tmux` or `screen`, or run in the background: `nohup python3 local_client.py > /tmp/openhome_client.log 2>&1 &`
* **Windows** — run in a minimized terminal, or use Task Scheduler
### 5. Install the Local Link Ability
Add the Local Link Ability from the [Abilities library](https://app.openhome.com/dashboard/abilities) to your Agent. Now test it: *"show current directory"*.
## How it works
```
User speaks → OpenHome STT → Ability receives transcription
↓
LLM converts to terminal command
↓
exec_local_command(command)
↓
local_client.py (WebSocket)
↓
Executes via subprocess.run
↓
Response ← LLM formats for voice ← Ability
```
## The template
The Ability uses a small system prompt to convert natural language into a shell command, executes it, then uses a second prompt to convert the output into a spoken response.
### The command-generation prompt
Tune this for your OS and use case. Default (macOS/Linux):
```python theme={"system"}
system_prompt = """
You are a terminal command generator. Your ONLY purpose is to convert user
requests into valid shell commands.
Rules:
- Respond ONLY with the terminal command, nothing else
- Use POSIX-compatible commands (bash/zsh)
- Do not include explanations, quotes, or markdown formatting
- Do not use sudo unless absolutely necessary
- Make sure commands are safe and won't harm the system
Examples:
User: "list all files" -> ls -la
User: "show current directory" -> pwd
User: "find python files" -> find . -name "*.py"
User: "check disk space" -> df -h
"""
```
For Windows, swap the examples to PowerShell or `cmd` equivalents.
### The core function
```python theme={"system"}
async def first_function(self):
user_inquiry = await self.capability_worker.wait_for_complete_transcription()
# Convert natural language → shell command
command = self.capability_worker.text_to_text_response(
user_inquiry, history=[], system=system_prompt,
).strip()
await self.capability_worker.speak(f"Running {command}")
response = await self.capability_worker.exec_local_command(command)
# Convert raw output → spoken response
spoken = self.capability_worker.text_to_text_response(
f"Format this for voice. Command: {command}\nOutput: {response}",
history=[],
system="Convert terminal output to a single spoken sentence under 15 words.",
)
await self.capability_worker.speak(spoken)
self.capability_worker.resume_normal_flow()
```
## `exec_local_command()` reference
```python theme={"system"}
async def exec_local_command(
self,
command: str | dict,
target_id: str | None = None,
timeout: float = 10.0,
)
```
**Parameters:**
* `command` *(required)* — terminal command to execute
* `target_id` *(optional)* — device identifier (default: `"laptop"`)
* `timeout` *(optional)* — max wait time in seconds (default: `10.0`)
## Example abilities
### 1. Git assistant
```python theme={"system"}
system_prompt = """
Convert git operations to commands. POSIX shell.
Examples:
"check git status" -> git status
"commit changes" -> git add . && git commit -m "Update"
"push to main" -> git push origin main
"create branch feature-x" -> git checkout -b feature-x
"""
```
### 2. System monitor (cross-platform)
```python theme={"system"}
async def first_function(self):
user_inquiry = await self.capability_worker.wait_for_complete_transcription()
if "system health" in user_inquiry.lower():
# Pick commands by OS — detect via Python in the client
metrics = {
"CPU": "top -l 1 | grep 'CPU usage'", # macOS
"Memory": "vm_stat | head -n 10", # macOS
"Disk": "df -h /",
"Battery": "pmset -g batt", # macOS
}
report = []
for name, cmd in metrics.items():
response = await self.capability_worker.exec_local_command(cmd)
report.append(f"{name}: {response}")
await self.capability_worker.speak(". ".join(report))
self.capability_worker.resume_normal_flow()
```
### 3. Dev environment launcher
```python theme={"system"}
async def first_function(self):
user_inquiry = await self.capability_worker.wait_for_complete_transcription()
if "start dev environment" in user_inquiry.lower():
await self.capability_worker.speak("Starting development environment...")
commands = [
"cd ~/Projects/my-app",
"code .", # Opens VS Code
"npm run dev &", # Background dev server
"open http://localhost:3000", # macOS — use `start` on Windows, `xdg-open` on Linux
]
for cmd in commands:
await self.capability_worker.exec_local_command(cmd, timeout=15.0)
await self.capability_worker.speak("Development environment is ready!")
self.capability_worker.resume_normal_flow()
```
### 4. File organization assistant
```python theme={"system"}
system_prompt = """
File management commands for POSIX systems.
Examples:
"organize downloads by type" ->
mkdir -p ~/Downloads/Images ~/Downloads/Documents &&
mv ~/Downloads/*.jpg ~/Downloads/Images/ 2>/dev/null || true &&
mv ~/Downloads/*.pdf ~/Downloads/Documents/ 2>/dev/null || true
"find large files" -> find ~ -type f -size +100M
"""
```
## Customizing the client
### Add custom command handlers
```python theme={"system"}
# In local_client.py
def execute_command(command):
if command == "get_battery":
result = subprocess.run(['pmset', '-g', 'batt'], capture_output=True)
return parse_battery_output(result.stdout)
result = subprocess.run(command, shell=True, capture_output=True)
return result.stdout.decode()
```
### Add logging
```python theme={"system"}
import logging
logging.basicConfig(filename='openhome_commands.log', level=logging.INFO)
def execute_command(command):
logging.info(f"Executing: {command}")
result = subprocess.run(command, shell=True, capture_output=True)
logging.info(f"Result: {result.stdout[:100]}...")
return result.stdout.decode()
```
### Add a command blocklist
```python theme={"system"}
BLOCKED_COMMANDS = ['rm -rf /', 'sudo', 'format', 'dd if=']
def execute_command(command):
if any(blocked in command for blocked in BLOCKED_COMMANDS):
return "ERROR: Blocked command for safety"
result = subprocess.run(command, shell=True, capture_output=True)
return result.stdout.decode()
```
## Best practices
### 1. Test commands manually first
```bash theme={"system"}
ls -la ~/Documents # test in terminal
# verify output matches expectations
# then add to the Ability
```
### 2. Use a whitelist for voice-only use cases
```python theme={"system"}
SAFE_COMMANDS = ['ls', 'pwd', 'cd', 'cat', 'grep', 'find', 'df', 'du']
if not any(safe in terminal_command for safe in SAFE_COMMANDS):
await self.capability_worker.speak("This command needs confirmation.")
# ... confirmation logic
```
### 3. Tune timeouts
```python theme={"system"}
# Quick commands: default is fine
response = await self.capability_worker.exec_local_command("pwd")
# Long-running: bump up
response = await self.capability_worker.exec_local_command(
"find / -name '*.log'",
timeout=60.0,
)
```
### 4. Handle errors and timeouts
```python theme={"system"}
try:
response = await self.capability_worker.exec_local_command(terminal_command)
if "error" in response.lower() or "permission denied" in response.lower():
await self.capability_worker.speak("That command failed. Try a different approach?")
else:
# process success
...
except asyncio.TimeoutError:
await self.capability_worker.speak("That took too long. It might still be running.")
except Exception as e:
self.worker.editor_logging_handler.error(f"Command failed: {e}")
await self.capability_worker.speak("Something went wrong.")
finally:
self.capability_worker.resume_normal_flow()
```
## Local Connect vs OpenClaw
| Feature | Local Connect | OpenClaw |
| ----------------- | ---------------------------- | ---------------------------- |
| **Setup** | Single Python file | CLI + daemon + LLM config |
| **Dependencies** | Python 3.7+ only | Node.js + LLM API key |
| **Complexity** | Minimal | Advanced, feature-rich |
| **Customization** | Direct Python editing | MCP-style integration |
| **Best for** | Simple commands, prototyping | Complex automation workflows |
Use **Local Connect** when you want direct terminal access with minimal setup. Use **[OpenClaw](/building-abilities/openclaw)** when you need robust LLM-powered automation.
## Troubleshooting
* Verify the API key in `local_client.py` is correct
* Check Python version: `python3 --version` (needs 3.7+)
* Check internet connection and any firewall blocking outbound WebSocket
* Check the client terminal for errors
* Verify the command runs correctly when typed manually
* Check logs: `tail -f /tmp/openhome_client.log`
* Restart the client
* Some commands need admin privileges. Avoid them in voice flows when possible
* Or modify the client to prompt for `sudo` password (advanced)
* Keep the session alive with `tmux` / `screen`
* Add reconnect logic to the client
* On macOS, check sleep settings — the client pauses when the system sleeps
* The template uses an LLM to reformat responses. Check `check_response_system_prompt` is correct
* Add command-specific parsing for structured output (JSON, tables)
## Security
This runs real terminal commands on your machine with your user permissions. Anyone with access to your OpenHome account can run commands via your client. Protect your API key.
### Recommended safety measures
**1. Command whitelist**
```python theme={"system"}
ALLOWED_COMMANDS = ['ls', 'pwd', 'df', 'du', 'cat', 'grep', 'find']
if not any(cmd in terminal_command for cmd in ALLOWED_COMMANDS):
await self.capability_worker.speak("That command is not allowed.")
return
```
**2. Confirm destructive actions** — always confirm before `rm`, `sudo`, `shutdown`, `dd`, `format`.
**3. Monitor client logs**
```bash theme={"system"}
# Check what commands were run
grep "Executing:" /tmp/openhome_client.log
# Alert on dangerous patterns
tail -f /tmp/openhome_client.log | grep -E "rm|sudo|shutdown"
```
## Architecture
```
Voice Input → OpenHome Ability → exec_local_command()
↓
local_client.py
(WebSocket connection)
↓
Terminal execution
(subprocess.run)
↓
Response ← AI formatting ← Template
```
## Resources
* **Local Link template** on GitHub: [openhome-dev/abilities/templates/Local](https://github.com/openhome-dev/abilities/tree/dev/templates/Local)
* **Heavier alternative:** [Connect to OpenClaw](/building-abilities/openclaw)
* **Getting started quickstart:** [Getting Started → OpenClaw](/guides/getting-started/openclaw) *(OpenClaw variant; Local Connect quickstart coming)*
# Controlling MQTT Devices
Source: https://docs.openhome.com/building-abilities/mqtt-device-control
Control MQTT smart devices from your OpenHome DevKit — register your devices, use them in your Abilities, and control them by voice.
OpenHome lets your Agent control MQTT smart devices — such as lights, plugs, sensors, and appliances — through your OpenHome DevKit. Once a device is connected to your DevKit's MQTT broker, an Ability can control it with the `send_devkit_mqtt_action` method by targeting the device's MQTT topic.
You can also register your devices in the dashboard. Registered devices are exposed to an Ability through `self.worker.mqtt_devices`, letting it discover the available devices at runtime — useful for voice Abilities that adapt to whatever devices a user has set up.
MQTT device control requires an OpenHome DevKit. It cannot run from web Agents.
## New to MQTT?
MQTT is a lightweight publish/subscribe messaging protocol used widely by IoT and smart-home devices: a device subscribes to a **topic**, and commands are sent by publishing to that topic. This page assumes familiarity with that basic model. For a deeper introduction to the protocol itself, see:
* [MQTT.org](https://mqtt.org) — the official protocol site
* [HiveMQ MQTT Essentials](https://www.hivemq.com/mqtt-essentials/) — an introductory guide
## Sending Actions with `send_devkit_mqtt_action`
`send_devkit_mqtt_action` is a `CapabilityWorker` method, called from an Ability's `main.py`. It instructs the DevKit to publish an MQTT command to a device on its broker.
```python theme={"system"}
async def send_devkit_mqtt_action(self, topic="", action="", value="", command="")
```
The method is fire-and-forget: it returns `None` immediately and does not wait for the device to respond.
A `None` return value is expected, not an error. Confirm an action succeeded by observing the device, not by inspecting the return value.
You don't need to register a device to control it. As long as the device is connected to your DevKit's broker, you can target it directly by its topic. Registering a device only makes it discoverable through `self.worker.mqtt_devices` — see [Registering Devices](#registering-devices).
### Parameters
| Parameter | Type | Description |
| --------- | ----- | ---------------------------------------------------------------------------------------- |
| `topic` | `str` | MQTT topic of the target device. |
| `action` | `str` | The operation to perform: `turn_on`, `turn_off`, or `custom`. |
| `value` | `str` | Payload for a `custom` command. Ignored for `turn_on` and `turn_off`. |
| `command` | `str` | The device-specific command for a `custom` action. Ignored for `turn_on` and `turn_off`. |
### Actions
| `action` | Description | Parameters used |
| ---------- | -------------------------------- | --------------------------- |
| `turn_on` | Powers the device on. | `topic` |
| `turn_off` | Powers the device off. | `topic` |
| `custom` | Sends a device-specific command. | `topic`, `command`, `value` |
`turn_on` and `turn_off` require only `topic`; the `command` and `value` arguments are ignored. Use `custom` for any other operation — such as brightness, color, temperature, or device modes — supplying the command and payload that the device expects.
### Examples
Power a device on and off:
```python theme={"system"}
await self.capability_worker.send_devkit_mqtt_action(
topic="living_room_light", action="turn_on"
)
await self.capability_worker.send_devkit_mqtt_action(
topic="living_room_light", action="turn_off"
)
```
Send a device-specific command with `custom`:
```python theme={"system"}
await self.capability_worker.send_devkit_mqtt_action(
topic="living_room_light", action="custom",
command="", value="",
)
```
The `command` and `value` for a `custom` action are specific to the target device. Record the commands a device supports when you register it, so an Ability has a reliable reference for what it can send. See [Registering Devices](#registering-devices).
## Default MQTT Configuration
The DevKit's MQTT broker is preconfigured and ready to use. To view or update it, open the dashboard and go to **OpenHome DevKit → MQTT**. This section shows the **DevKit IP** along with the broker's **username**, **password**, and **port** (the default port is `1883`).
To change the broker's username and password, use the **Update MQTT** button in this section. The DevKit applies the new credentials to its broker.
### Connecting a Device to the Broker
A physical MQTT device must connect to the DevKit's broker before it can be controlled. In that device's own MQTT connection settings, enter the values shown in the MQTT section:
| Device setting | Value |
| --------------------- | ---------------------------------------------------- |
| Host / Broker address | The **DevKit IP** shown in the MQTT section. |
| Port | The port shown in the MQTT section (default `1883`). |
| Username | The username shown in the MQTT section. |
| Password | The password shown in the MQTT section. |
Every device must connect to the same broker — the one running on your DevKit. Use the DevKit IP from the MQTT section as the broker host on each device.
## Registering Devices
Registering a device makes it known to the DevKit and available to an Ability. Add a device from the **OpenHome DevKit → MQTT** section with the following fields:
| Field | Description |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | A friendly name for the device, such as *Living Room Light*. |
| **Topic** | The device's MQTT topic. This is the value passed as `topic` to `send_devkit_mqtt_action`. |
| **Command** | The list of commands the device supports. When an Ability uses the LLM to decide what to send, this list is the LLM's reference — the clearer and more complete it is, the better the LLM can pick the right command and value for a request. |
| **Description** | A short description of the device, such as its room or type. The LLM uses this to identify the device and tell it apart from others, so keep it specific. |
Registered devices become available to an Ability at runtime through `self.worker.mqtt_devices` — a list the Ability reads to discover its devices and decide which one to control. The **Command** and **Description** you enter here travel with each device in that list, so writing them clearly directly improves how well an LLM-driven Ability picks the right device and command. The next section covers how an Ability reads and uses this list.
## Accessing Registered Devices with `self.worker.mqtt_devices`
Within an Ability, the registered devices are available as `self.worker.mqtt_devices` — a list of dictionaries, one per device:
```python theme={"system"}
[
{
"name": "Bedroom Light",
"topic": "tasmota_channel",
"commands": "/HSBColor hue,sat,bri (0-360,0-100,0-100); /Dimmer 0-100; /CT 153-500 (warm→cool); /Color R,G,B 0-255",
"description": "RGBCW smart bulb — full RGB color plus tunable warm-to-cool white, dimmable. Controlled over MQTT (Tasmota).",
},
# one entry per registered device
]
```
An Ability reads this list to determine which devices are available, selects the appropriate one, and controls it using its `topic`:
```python theme={"system"}
devices = self.worker.mqtt_devices or []
target = devices[0]
await self.capability_worker.send_devkit_mqtt_action(
topic=target["topic"], action="turn_on"
)
```
A common pattern is to pass the entire `mqtt_devices` list to the LLM, allowing it to map a spoken request to the correct device — using each device's `commands` and `description` to determine the `topic`, `action`, and, for a `custom` action, the `command` and `value`. The example below follows this pattern.
## Example: A Smart Home Ability
This Ability reads the registered devices from `self.worker.mqtt_devices`, sends the request and the device list to the LLM, and publishes the action the LLM returns with `send_devkit_mqtt_action`. Because the LLM acts as the orchestrator, the same code handles any registered device — to support a new device, register it in the dashboard; no code changes are required.
Suppose a device like this is registered in the dashboard:
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------------------------ |
| **Name** | Bedroom Light |
| **Topic** | `tasmota_channel` |
| **Command** | `/HSBColor hue,sat,bri (0-360,0-100,0-100); /Dimmer 0-100; /CT 153-500 (warm→cool); /Color R,G,B 0-255` |
| **Description** | RGBCW smart bulb — full RGB color plus tunable warm-to-cool white, dimmable. Controlled over MQTT (Tasmota). |
The Ability reads this through `self.worker.mqtt_devices` and passes it to the LLM, which uses the device's command list and description to map a request like *"make the bedroom light blue"* to `topic="tasmota_channel"`, `action="custom"`, `command="/HSBColor"`, and `value="240,100,100"`.
```python theme={"system"}
import json
import re
from src.agent.capability import MatchingCapability
from src.agent.capability_worker import CapabilityWorker
from src.main import AgentWorker
# The LLM is the orchestrator: it reads the request + the device list and returns
# one JSON action. Tune this prompt to change behaviour — no code edits needed.
ORCHESTRATOR_PROMPT = """You control smart-home devices over MQTT. Read the user's request, pick the single device they mean, and decide the command to send it.
Devices:
{devices}
Each line gives a device's name, MQTT topic, and description, and may also list MQTT "commands" the device supports.
How to choose the device:
- Match on the device name, its location (e.g. "bedroom", "living room"), or its description.
- If several devices fit or you can't tell which one, ask instead of guessing.
How to choose the action:
- Simple power on/off → "turn_on" / "turn_off". Leave "command" and "value" empty.
- Anything else (brightness, color, temperature, modes, etc.) → "custom" with an MQTT "command" and "value":
- If the device lists "commands", use them as your main reference, but you may still infer a command yourself when the request needs one that isn't listed.
- If it lists no "commands", infer a sensible command and value from the device's name, description, and how such devices normally work over MQTT (e.g. Tasmota: Dimmer 0-100, HSBColor h,s,b, CT 153-500).
- Pick exactly one device. Keep "reply" natural, spoken, and under 20 words.
Reply with ONLY this JSON object, nothing else:
{{"topic": "", "action": "turn_on" | "turn_off" | "custom", "command": "", "value": "", "reply": ""}}
If the request matches no device, several devices fit, or you can't determine a command, reply instead with:
{{"ask": ""}}
Examples (illustrative only — use the real devices listed above):
- "turn on the kitchen light" -> {{"topic": "kitchen_light", "action": "turn_on", "command": "", "value": "", "reply": "Turning on the kitchen light."}}
- "dim the bedroom lamp to 30 percent" -> {{"topic": "bedroom_lamp", "action": "custom", "command": "Dimmer", "value": "30", "reply": "Setting the bedroom lamp to 30 percent."}}
- "make the living room bulb blue" -> {{"topic": "living_room_bulb", "action": "custom", "command": "HSBColor", "value": "240,100,100", "reply": "Turning the living room bulb blue."}}
- "turn on the light" (when several lights exist) -> {{"ask": "Which light do you mean — kitchen, bedroom, or living room?"}}
User request: {request}"""
class SmartHomeAssistantCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
# Do not change following tag of register capability
# {{register capability}}
def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self)
self.worker.session_tasks.create(self.run())
def format_devices(self, devices: list) -> str:
entries = []
for device in devices:
entry = f"- {device['name']} (topic: {device['topic']})"
if device["description"]:
entry += f" — {device['description']}"
if device["commands"]:
entry += f" [commands: {device['commands']}]"
entries.append(entry)
return "\n".join(entries)
def decide(self, request: str, devices: list) -> dict:
prompt = ORCHESTRATOR_PROMPT.format(
devices=self.format_devices(devices),
request=request,
)
history = self.capability_worker.get_full_message_history()
raw = self.capability_worker.text_to_text_response(prompt, history)
match = re.search(r"\{.*\}", raw or "", re.DOTALL)
if match:
try:
parsed = json.loads(match.group(0))
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
return {}
async def run(self):
try:
request = await self.capability_worker.wait_for_complete_transcription() or ""
devices = self.worker.mqtt_devices
if not devices:
await self.capability_worker.speak(
"There are no devices added. Please add your devices in the OpenHome DevKit MQTT section."
)
return
action = self.decide(request, devices)
# One clarification round if the model needs more detail.
if action.get("ask"):
answer = await self.capability_worker.run_io_loop(action["ask"]) or ""
action = self.decide(f"{request}. {answer}", devices)
if not action.get("topic"):
await self.capability_worker.speak(
action.get("ask") or "Sorry, I couldn't tell which device you meant, please try again."
)
return
self.worker.editor_logging_handler.info(f"[SmartHome] {action}")
await self.capability_worker.send_devkit_mqtt_action(
topic=action["topic"],
action=action.get("action", "custom"),
value=action.get("value", ""),
command=action.get("command", ""),
)
await self.capability_worker.speak(action.get("reply", "Done."))
except Exception as e:
self.worker.editor_logging_handler.error(f"[SmartHome] Error: {e!r}")
await self.capability_worker.speak("Something went wrong with that request.")
finally:
self.capability_worker.resume_normal_flow()
```
### Interaction Flow
`main.py` waits for the user's full request with `wait_for_complete_transcription()`.
The Ability reads `self.worker.mqtt_devices`. If no devices are registered, it asks the user to add them in the **OpenHome DevKit → MQTT** section and exits.
`format_devices()` renders the device list into the prompt, and the LLM returns a single JSON action — a `topic` and `action` (plus `command` and `value` for `custom`) with a spoken `reply`, or an `ask` when it needs clarification.
If the LLM returns `ask`, the Ability poses the question with `run_io_loop()` and decides once more with the added detail.
The Ability calls `send_devkit_mqtt_action()` with the resolved `topic`, `action`, `command`, and `value`. The DevKit publishes the command to the device.
Because the call is fire-and-forget, the Ability speaks the LLM's `reply` to confirm, then calls `resume_normal_flow()` so the Agent returns to its normal flow.
## Best practices
The `Command` field you set when registering a device is the reference an Ability and the LLM rely on. List the commands the device actually supports so requests map to valid commands.
Only `turn_on` and `turn_off` ignore `command` and `value`. For brightness, color, temperature, or device modes, use `action="custom"` with the device-specific command and payload.
A device is only controllable once it connects to the DevKit's broker. Configure each device with the DevKit IP, port, and credentials from the MQTT section.
`send_devkit_mqtt_action` is fire-and-forget and returns `None`. Confirm the result by the device's behaviour, and speak a short confirmation to the user.
When `self.worker.mqtt_devices` is empty, guide the user to register devices in the MQTT section rather than failing silently.
## See also
* [Local Abilities](/building-abilities/local-ability) — run code on the DevKit for direct, low-level device control
* [Home Assistant](/devkit/home-assistant/get-started) — install Home Assistant on the DevKit and control devices by voice
* [How to Build an Ability](/building-abilities/how-to-build) — the fundamentals of building an Ability
* [SDK Reference](/api-sdk/sdk-reference) — the full CapabilityWorker method catalog
# Connect to OpenClaw
Source: https://docs.openhome.com/building-abilities/openclaw
Give your OpenHome agent the ability to control your computer through OpenClaw — full setup, SDK reference, and example abilities.
OpenClaw lets your OpenHome agent control your local machine through voice — launch apps, monitor system status, manage files, run developer workflows, and more. The Ability sends a command through OpenHome's `exec_local_command()` call; the OpenClaw client executes it on your machine; the result comes back as a spoken response.
For a short task-oriented quickstart, see [Getting Started → OpenClaw](/guides/getting-started/openclaw). This page is the full reference.
## What you can build
* Application launcher and manager
* System monitoring and diagnostics
* File and folder automation
* Development environment controller
* Custom workflow automations
* Smart home integration via computer
* Screenshot and screen recording tools
* Clipboard and text manipulation
## Setup
### 1. Install OpenClaw
OpenClaw must be installed and configured on your local machine with an LLM API key.
```bash theme={"system"}
npm install -g openclaw@latest
openclaw onboard --install-daemon
```
During onboarding you'll be prompted for an LLM API key (OpenAI, Anthropic, etc.). OpenClaw uses this key to interpret natural-language commands.
### 2. Download the OpenClaw client
[Download for your OS](https://drive.google.com/drive/folders/10qK75I-bFB2D98YJ6dH3tQFsvEk44Y7-):
* **Windows** — `.exe` installer
* **macOS** — `.dmg` or `.app`
* **Linux** — AppImage or `.deb`
### 3. Run the client
* **Windows** — run the `.exe`, allow permissions if prompted
* **macOS** — if blocked, go to System Settings → Privacy & Security → **Open Anyway**
* **Linux** — `chmod +x` and run, grant required permissions
Copy it from [Dashboard → Settings → API Keys](https://app.openhome.com/dashboard/settings).
Paste the key into the OpenClaw client, click **Connect**. Wait for the *"welcome"* message in the logs — that confirms a live connection.
### 4. Add the OpenClaw Ability
Install the OpenClaw Ability from the [Abilities library](https://app.openhome.com/dashboard/abilities) on your Agent. This is the template you'll customize for your use case.
## The `exec_local_command()` API
One function carries every command from the Ability to the OpenClaw client.
```python theme={"system"}
async def exec_local_command(
self,
command: str | dict,
target_id: str | None = None,
timeout: float = 10.0,
)
```
**Parameters:**
* `command` *(str | dict, required)* — inquiry or command for OpenClaw
* `target_id` *(str | None)* — target device identifier (default: `"laptop"`)
* `timeout` *(float)* — max seconds to wait for a response (default: `10.0`)
**Returns:** `str` — response from OpenClaw (success message, error, or command output).
### Usage
```python theme={"system"}
# Basic
response = await self.capability_worker.exec_local_command(user_inquiry)
# Long-running command — longer timeout
response = await self.capability_worker.exec_local_command(
"compile large project",
timeout=30.0,
)
# Specific target device
response = await self.capability_worker.exec_local_command(
"check battery status",
target_id="laptop",
)
```
## How it works
1. User speaks a computer-control command
2. OpenHome captures voice as text
3. Ability sends the command to your local OpenClaw client via `exec_local_command()`
4. OpenClaw executes on your computer
5. OpenClaw returns the result (success / failure / output)
6. An LLM converts the technical output into a natural spoken response (max \~15 words)
7. OpenHome speaks the result
## Example abilities
### 1. Development environment controller
```python theme={"system"}
# Trigger: "start coding session"
# Opens IDE, starts local servers, opens documentation
async def first_function(self):
commands = [
"open Visual Studio Code",
"start local dev server on port 3000",
"open browser to localhost:3000",
]
for cmd in commands:
await self.capability_worker.exec_local_command(cmd)
await self.capability_worker.speak("Development environment is ready.")
self.capability_worker.resume_normal_flow()
```
### 2. System health monitor
```python theme={"system"}
# Trigger: "check system health"
async def first_function(self):
metrics = [
("CPU usage", "get cpu usage"),
("Memory usage", "get memory usage"),
("Disk space", "get disk usage"),
("Battery level", "get battery level"),
]
report = []
for name, cmd in metrics:
response = await self.capability_worker.exec_local_command(cmd)
report.append(f"{name}: {response}")
await self.capability_worker.speak(", ".join(report))
self.capability_worker.resume_normal_flow()
```
### 3. Smart screenshot
```python theme={"system"}
# Trigger: "take screenshot of active window"
async def first_function(self):
user_inquiry = await self.capability_worker.wait_for_complete_transcription()
if "full screen" in user_inquiry.lower():
cmd = "screenshot fullscreen save to ~/Desktop"
elif "active window" in user_inquiry.lower():
cmd = "screenshot active window save to ~/Desktop"
else:
cmd = "screenshot selection save to ~/Desktop"
response = await self.capability_worker.exec_local_command(cmd, timeout=15.0)
await self.capability_worker.speak(f"Screenshot saved: {response}")
self.capability_worker.resume_normal_flow()
```
### 4. App manager with confirmation
```python theme={"system"}
# Trigger: "close all browsers"
async def first_function(self):
response = await self.capability_worker.exec_local_command("list open browsers")
if "none" in response.lower():
await self.capability_worker.speak("No browsers are open.")
self.capability_worker.resume_normal_flow()
return
await self.capability_worker.speak(f"Found: {response}. Close all?")
confirmation = await self.capability_worker.user_response()
if "yes" in confirmation.lower():
await self.capability_worker.exec_local_command("close all browsers")
await self.capability_worker.speak("All browsers closed.")
else:
await self.capability_worker.speak("Cancelled.")
self.capability_worker.resume_normal_flow()
```
## Best practices
### 1. Define clear trigger words
Specific, unambiguous triggers beat generic ones:
* ✅ `start development session`, `launch dev environment`, `open my coding setup`
* ❌ `start`, `go`, `do it`
Avoid trigger phrases that collide with other Abilities.
### 2. Validate before executing
```python theme={"system"}
DANGEROUS_COMMANDS = ["rm -rf", "format", "delete system", "shutdown -h now"]
if any(danger in user_inquiry.lower() for danger in DANGEROUS_COMMANDS):
await self.capability_worker.speak("I can't execute that for safety reasons.")
return
```
### 3. Confirm destructive actions
```python theme={"system"}
if "restart" in user_inquiry.lower() or "shutdown" in user_inquiry.lower():
confirmed = await self.capability_worker.run_confirmation_loop(
"This will restart your computer. Are you sure?"
)
if not confirmed:
await self.capability_worker.speak("Cancelled.")
return
```
### 4. Tune timeouts
```python theme={"system"}
# Default is fine for quick commands
response = await self.capability_worker.exec_local_command("open Chrome")
# Long-running — bump the timeout
response = await self.capability_worker.exec_local_command(
"compile entire project",
timeout=60.0,
)
```
### 5. Format responses for voice
Don't just echo raw OpenClaw output. Parse and shape:
```python theme={"system"}
response = await self.capability_worker.exec_local_command("get battery level")
# Raw: "Battery: 73% (charging, 2:15 remaining)"
# Spoken: "Battery is at 73 percent."
battery = extract_percentage(response)
await self.capability_worker.speak(f"Battery is at {battery} percent.")
```
Keep spoken output to **1 sentence, 15 words or less**.
### 6. Handle errors and timeouts
```python theme={"system"}
try:
response = await self.capability_worker.exec_local_command(user_inquiry, timeout=15.0)
if "error" in response.lower() or "failed" in response.lower():
await self.capability_worker.speak("That command didn't work. Try something else.")
else:
# process success
...
except asyncio.TimeoutError:
await self.capability_worker.speak("That took too long. It might still be running.")
except Exception as e:
self.worker.editor_logging_handler.error(f"Command failed: {e}")
await self.capability_worker.speak("Something went wrong. Check the logs.")
```
### 7. Chain commands for workflows
```python theme={"system"}
workflow = [
("Opening calendar", "open Calendar app"),
("Starting video", "open Zoom"),
("Opening notes", "open Notes app"),
]
for description, command in workflow:
await self.capability_worker.speak(description)
await self.capability_worker.exec_local_command(command)
await asyncio.sleep(1)
await self.capability_worker.speak("Ready for your meeting.")
```
## Troubleshooting
* Verify your API key is correct (from Dashboard → Settings → API Keys)
* Check the daemon is running: `openclaw status`
* Restart the OpenClaw client app
* Check the client logs for error messages
* Increase timeout: `exec_local_command(command, timeout=20.0)`
* Check daemon status: `openclaw status`
* Verify the command is valid for your OS
* Review OpenClaw client logs
* System Settings → Privacy & Security → find the blocked app → **Open Anyway**
* Grant Accessibility and Automation permissions when prompted
* Confirm the client shows *"Connected"*
* Test a safe command first: *"what time is it"*
* Verify the Ability is registered on your Agent
* Review OpenClaw client logs
## Security & privacy
OpenClaw runs with **your user permissions** on the local machine. Commands execute exactly as if you typed them in a terminal.
* Commands run locally — not sent to external servers (the LLM used by OpenClaw may receive the natural-language text for command generation)
* Your OpenHome API key authenticates the OpenHome → OpenClaw connection
* **Always add validation** for user-provided input
* **Use confirmation prompts** for destructive operations (restart, delete, format)
* Review all permissions carefully when installing the client
## Architecture
```
Voice Input → OpenHome Ability → exec_local_command()
↓
OpenClaw Client
(via WebSocket)
↓
OpenClaw Daemon
(with LLM API)
↓
Local System Execution
(apps, files, etc.)
↓
Response ← AI Formatting ← Template
```
## Resources
* **OpenClaw template** on GitHub: [openhome-dev/abilities/templates/OpenClaw](https://github.com/openhome-dev/abilities/tree/dev/templates/OpenClaw)
* **OpenClaw CLI help:** `openclaw --help`
* **Check status:** `openclaw status`
* **Quick start checklist:** [Getting Started → OpenClaw](/guides/getting-started/openclaw)
* **Lightweight alternative:** [Local Connect](/building-abilities/local-connect)
# OpenHome Ability Templates
Source: https://docs.openhome.com/building-abilities/templates
Complete guide to OpenHome ability templates, architecture patterns, code snippets, and dashboard setup flow.
# OpenHome Ability Templates
> Five templates. Four ability categories. Unlimited possibilities.
OpenHome ability templates are starter blueprints. They are intentionally minimal and focused on runtime architecture, not polished UX.
## What OpenHome Actually Is
OpenHome runs AI agents that can trigger Python abilities. Those abilities can:
* control local tools through bridge methods
* call APIs and external services
* process ambient audio in loops
* store and share state over time
* run continuously as background daemons
LLMs are central in this model. They route, transform, and summarize, while abilities execute concrete actions.
## Ability Categories
| Category | Trigger | Lifecycle | Entry File |
| ----------------- | -------------------------- | -------------------------------------------- | --------------------------------- |
| Skill | User hotword | Runs once, exits | `main.py` |
| Agent Controlled | Agent routing | Runs on demand when the Agent delegates work | `main.py` |
| Background Daemon | Automatic on session start | Runs continuously until session end | `background.py` |
| Local | User hotword | Runs on-device with hardware access | `main.py` + `devkit_functions.py` |
Notes:
* Agent Controlled templates are still being finalized.
## Background Daemon Entry Contract
For daemon templates, `background.py` must be named exactly `background.py`.
```python theme={"system"}
def call(self, worker, 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 To Use Templates In Dashboard
1. Click **Create** on `https://app.openhome.com/dashboard/home`.
2. Select **Agent Ability**.
3. Fill the **Create Ability** form and choose any category for your ability.
4. Fill **Ability Behavior**, then choose the template you want.
5. Click **Save Ability**.
## Template Directory
```text theme={"system"}
templates/
│
├── basic-template/ ← Minimal Skill skeleton
├── api-template/ ← Call an external REST API
├── loop-template/ ← Multi-turn looping Skill
├── music-template/ ← Stream a track, branch on how playback ended
│
├── SendEmail/ ← Fire-and-forget action
├── Local/ ← LLM-to-local-command translation
├── OpenClaw/ ← Route request to OpenClaw
│
├── Background/ ← Standalone background daemon
├── Alarm/ ← Skill + Background daemon combo
│
└── ReadWriteFile/ ← Shared file storage / IPC pattern
```
## Template README Sources
* [Alarm README](https://github.com/openhome-dev/abilities/blob/dev/templates/Alarm/README.md)
* [Local README](https://github.com/openhome-dev/abilities/blob/dev/templates/Local/README.md)
* [ReadWriteFile README](https://github.com/openhome-dev/abilities/blob/dev/templates/ReadWriteFile/README.md)
* [SendEmail README](https://github.com/openhome-dev/abilities/blob/dev/templates/SendEmail/README.md)
* [Background README](https://github.com/openhome-dev/abilities/blob/dev/templates/Background/README.md)
* [api-template README](https://github.com/openhome-dev/abilities/blob/dev/templates/api-template/README.md)
* [basic-template README](https://github.com/openhome-dev/abilities/blob/dev/templates/basic-template/README.md)
* [loop-template README](https://github.com/openhome-dev/abilities/blob/dev/templates/loop-template/README.md)
* [music-template README](https://github.com/openhome-dev/abilities/blob/dev/templates/music-template/README.md)
* [OpenClaw README](https://github.com/openhome-dev/abilities/blob/dev/templates/OpenClaw/README.md)
## Start Here Templates
### `basic-template` (Skill, minimal)
Purpose: absolute minimum lifecycle implementation.
```python theme={"system"}
async def run(self):
await self.capability_worker.speak("Hi! How can I help you today?")
user_input = await self.capability_worker.user_response()
response = self.capability_worker.text_to_text_response(
f"Give a short, helpful response to: {user_input}"
)
await self.capability_worker.run_io_loop(response + " Are you satisfied with the response?")
await self.capability_worker.speak("Thank you for using the advisor. Goodbye!")
self.capability_worker.resume_normal_flow()
```
### `api-template` (Skill, external API)
Purpose: ask user input, call API, summarize result, exit cleanly.
```python theme={"system"}
async def run(self):
await self.capability_worker.speak("Sure! What would you like me to look up?")
user_input = await self.capability_worker.user_response()
result = await self.fetch_data(user_input)
if result:
spoken = self.capability_worker.text_to_text_response(
f"Summarize this data in one short sentence for voice: {result}"
)
await self.capability_worker.speak(spoken)
else:
await self.capability_worker.speak("Sorry, I couldn't get that information right now.")
self.capability_worker.resume_normal_flow()
```
### `loop-template` (Skill, long-running loop)
Purpose: interactive multi-turn skill with explicit exit words.
```python theme={"system"}
EXIT_WORDS = {"stop", "exit", "quit", "done", "cancel", "bye", "goodbye", "leave"}
async def run(self):
await self.capability_worker.speak("I'm ready to help. Ask me anything, or say stop.")
while True:
user_input = await self.capability_worker.user_response()
if not user_input:
continue
if any(word in user_input.lower() for word in EXIT_WORDS):
await self.capability_worker.speak("Goodbye!")
break
response = self.capability_worker.text_to_text_response(
f"Respond in one short sentence: {user_input}"
)
await self.capability_worker.speak(response)
self.capability_worker.resume_normal_flow()
```
### `music-template` (Skill, interruptible playback)
Purpose: play a track for minutes, then branch on how playback ended.
`stream_music_from_url()` blocks until playback genuinely ends and returns `outcome`: `finished`, `paused`, `stopped`, `unplayable` or `error`. Only `"paused"` continues the loop, and a resume is the same call again with the same `url`. See [Music Playback](/building-abilities/how-to-build#music-playback).
```python theme={"system"}
# Resolved once, not per pass: handing the SAME url back is what resumes a
# paused track, so a fresh url would restart it from the top.
url = self.stream_url(track)
announced = False
while True:
result = await self.capability_worker.stream_music_from_url(
url,
f"Bearer {self.api_key}", # "" if the host wants none
announce=f"Playing {track['title']}." if not announced else "",
)
announced = True
if result["outcome"] != "paused":
break
reply = await self.capability_worker.run_io_loop("Paused. Say resume or stop.")
if "resume" not in (reply or "").lower():
break
await self.capability_worker.speak("Okay, that's it for the music.")
self.capability_worker.resume_normal_flow()
```
## The Five Core Templates
### 1. `SendEmail` (Skill · Fire-and-forget)
What it demonstrates:
* one trigger, one action, one status response
* synchronous SDK call in async flow
* required handoff to `resume_normal_flow()`
```python theme={"system"}
async def email_sender(self):
status = self.capability_worker.send_email(
host="smtp.gmail.com",
port=465,
sender_email="test@gmail.com",
sender_password="app-password",
receiver_email="receiver_test@gmail.com",
cc_emails=[],
subject="Test Email",
body="Hello from OpenHome!",
attachment_paths=["testfile.txt"],
)
await self.capability_worker.speak(
"Email has been sent successfully." if status else "Failed to send email"
)
self.capability_worker.resume_normal_flow()
```
Production upgrades:
* collect recipient/body from user, do not hardcode
* add confirmation step before send
* secure credential handling
### 2. `OpenHome-local` (Skill · LLM-as-translator)
What it demonstrates:
* user speech to terminal command generation
* local execution bridge with `exec_local_command()`
* second LLM pass to explain execution result
```python theme={"system"}
user_inquiry = await self.capability_worker.wait_for_complete_transcription()
terminal_command = self.capability_worker.text_to_text_response(
user_inquiry, [], self.get_system_prompt()
).strip()
await self.capability_worker.speak(f"Running command: {terminal_command}")
response = await self.capability_worker.exec_local_command(terminal_command)
result = self.capability_worker.text_to_text_response(
f"check if the command successfully ran? response is: {response}",
[{"role": "user", "content": user_inquiry}],
"Explain success/failure in simple spoken language.",
)
await self.capability_worker.speak(result)
self.capability_worker.resume_normal_flow()
```
Production upgrades:
* command allowlist / denylist
* confirmation before high-risk commands
* timeout and error boundaries
### 3. `openclaw-template` (Skill · Sandbox escape)
What it demonstrates:
* pass-through request routing to OpenClaw
* no terminal generation layer, direct local AI routing
```python theme={"system"}
user_inquiry = await self.capability_worker.wait_for_complete_transcription()
await self.capability_worker.speak("Sending Inquiry to OpenClaw")
response = await self.capability_worker.exec_local_command(user_inquiry)
await self.capability_worker.speak(response["data"])
self.capability_worker.resume_normal_flow()
```
Production upgrades:
* robust response validation
* fallback for unmatched tools
* timeout + retry strategy
### 4. `Background` + `Alarm` (Background daemon pattern)
What it demonstrates:
* continuous background loop with `session_tasks.sleep()`
* reading session history / file state periodically
* skill + daemon coordination through shared storage
Background loop pattern:
```python theme={"system"}
async def first_function(self):
while True:
history = self.capability_worker.get_full_message_history()[-10:]
for message in history:
self.worker.editor_logging_handler.info(
f"Role: {message.get('role','')}, Message: {message.get('content','')}"
)
await self.worker.session_tasks.sleep(20.0)
```
Alarm background fire pattern:
```python theme={"system"}
if now >= target_dt:
await self.capability_worker.send_interrupt_signal()
await self.capability_worker.play_from_audio_file("alarm.mp3")
await self._mark_alarm_triggered(alarms, alarm_id)
```
Critical daemon rules:
* use `session_tasks.sleep()`, not `asyncio.sleep()`
* do not call `resume_normal_flow()` in daemon loops
* interrupt before daemon speech/audio when needed
* persist JSON safely (see `ReadWriteFile` rules)
* background file name must be exactly `background.py`
### 5. `loop-template` a.k.a. Log-My-Life pattern (Skill · Ambient observer)
What it demonstrates:
* long-running session loop
* periodic capture/process/respond
* explicit phrase-based exit
Core ambient loop shape:
```python theme={"system"}
while self.is_running:
self.capability_worker.start_audio_recording()
exit_requested = await self.wait_for_interval_or_exit()
self.capability_worker.stop_audio_recording()
audio_bytes = self.capability_worker.get_audio_recording()
await self.process_chunk(audio_bytes, recording_length)
if exit_requested:
self.is_running = False
```
Important audio detail:
* `get_audio_recording()` may return cumulative recording data.
* If chunking externally, track prior byte offset and slice new data before transcription/analysis.
## Utility Pattern: `ReadWriteFile`
Purpose: safe shared-state and IPC pattern across Skill and Daemon.
```python theme={"system"}
if await self.capability_worker.check_if_file_exists("temp_data.txt", in_ability_directory=False):
await self.capability_worker.write_file(
"temp_data.txt",
f"\n{time()}: {user_response}",
in_ability_directory=False
)
else:
await self.capability_worker.write_file(
"temp_data.txt",
f"{time()}: {user_response}",
in_ability_directory=False
)
file_data = await self.capability_worker.read_file("temp_data.txt", in_ability_directory=False)
```
For JSON files:
* always use delete-then-write when replacing full file content
* append mode is still good for `.txt` and `.log` activity streams
## Utility Pattern: `.md` Context Injection
Purpose: feed ambient context into the Agent prompt through persistent markdown files.
```python theme={"system"}
content = "## Emotional State\n- Current: frustrated (confidence: 0.87)\n"
exists = await self.capability_worker.check_if_file_exists("audio_emotion.md", in_ability_directory=False)
if exists:
await self.capability_worker.delete_file("audio_emotion.md", in_ability_directory=False)
await self.capability_worker.write_file("audio_emotion.md", content, in_ability_directory=False)
```
Rules:
* only persistent `.md` files are injected into Agent context
* reserve `user_profile.md` and `user_summary.md` for platform memory background ownership
* keep each injected `.md` file short and current-state focused
* expect \~60-90 seconds before a newly written `.md` file is reflected in responses
See: [Agent Memory & Context Injection](/agent_memory_context_injection)
## Utility Pattern: Key-Value Context Storage
Purpose: structured state for preferences, conversation workflows, feature flags, and cache metadata.
Notes:
* Key-value methods are synchronous (do not `await`).
* Store JSON dictionaries (`dict`) as values.
```python theme={"system"}
existing = self.capability_worker.get_single_key("conversation_456_state")
if existing:
self.capability_worker.update_key(
"conversation_456_state",
{"step": "confirmed", "intent": "book_flight", "destination": "Dubai"},
)
else:
self.capability_worker.create_key(
"conversation_456_state",
{"step": "awaiting_confirmation", "intent": "book_flight", "destination": "Dubai"},
)
```
Related methods:
* `create_key(key, value)`
* `update_key(key, value)`
* `delete_key(key)`
* `get_all_keys()`
* `get_single_key(key)`
* missing-key-safe pattern: read with `get_single_key()` before `update_key()`, create when absent
## How Templates Map To Ability Types
| Template | Type | Key SDK Methods |
| ------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------- |
| `SendEmail` | Skill | `send_email()`, `speak()`, `resume_normal_flow()` |
| `OpenHome-local` | Skill | `wait_for_complete_transcription()`, `text_to_text_response()`, `exec_local_command()` |
| `openclaw-template` | Skill | `wait_for_complete_transcription()`, `exec_local_command()`, `speak()` |
| `Background` | Background Daemon | `get_full_message_history()`, `session_tasks.sleep()` |
| `Alarm` | Skill + Daemon | `read_file()`, `write_file()`, `send_interrupt_signal()`, `play_from_audio_file()`, `session_tasks.sleep()` |
| `loop-template` | Skill (long-running) | `start_audio_recording()`, `stop_audio_recording()`, `get_audio_recording()`, `text_to_text_response()` |
| `ReadWriteFile` | Utility / IPC | `check_if_file_exists()`, `read_file()`, `write_file()`, `delete_file()` |
| `Context Storage` | Utility / State | `create_key()`, `update_key()`, `get_single_key()`, `get_all_keys()`, `delete_key()` |
| `basic-template` | Skill | `speak()`, `user_response()`, `run_io_loop()`, `resume_normal_flow()` |
| `api-template` | Skill | `user_response()`, `text_to_text_response()`, `resume_normal_flow()` |
## Critical Technical Rules
| Rule | Why |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| Call `resume_normal_flow()` on every Skill exit path | Returns control to the main agent flow |
| Do not use `resume_normal_flow()` inside daemon loops | Daemons are independent long-running tasks |
| Use `call(self, worker, background_daemon_mode)` in `background.py` | Ensures daemon startup contract is correct |
| Prefer `session_tasks.sleep()` over `asyncio.sleep()` | Ensures proper cleanup on session end |
| Treat JSON writes carefully | Default `write_file` mode appends and can corrupt JSON |
| Use persistent `.md` files for ambient prompt context | Memory background injects user-level markdown into Agent prompt |
| Use key-value context storage for structured state | `create_key`/`update_key`/`get_single_key` reduce JSON file bookkeeping |
| Validate local command execution | Prevent unsafe/destructive operations |
## What You Can Build From These
* voice-composed email flows
* local machine operator abilities
* OpenClaw orchestration flows
* background profilers, summarizers, and schedulers
* ambient capture and extraction assistants
These templates are the foundation layer. Start with the closest pattern, keep lifecycle rules strict, and then harden for production.
***
# What Makes a Good Ability
Source: https://docs.openhome.com/building-abilities/what-makes-a-good-ability
Voice UX, architecture patterns, and real-world examples for building OpenHome Abilities
This page focuses on **implementation patterns** — runtime, triggers, routing, APIs, memory, exit behavior, code examples.
For adjacent material:
* Product design philosophy, archetypes, 170+ ideas → [Designing OpenHome Abilities](/building-abilities/designing-abilities)
* Every SDK method, every sandbox rule → [SDK Reference](/api-sdk/sdk-reference)
* Voice UX rules → [Voice-First Best Practices](/guides/best-practices/voice-first)
## What Makes a Good Ability
Every OpenHome Agent is powered by an LLM out of the box. That means your Agent can already handle a lot natively — no Ability needed:
* Unit conversions, math, and calculations
* Translations, writing help, and grammar checks
* Trivia, general knowledge, definitions, and explanations
> **If the LLM can already answer it in conversation, it's not adding value as an Ability.**
It's also worth knowing that every Agent has a **Description Prompt** in its settings — this is the system-level LLM instruction that defines how your Agent behaves, its tone, its role, and its boundaries. If what you want is a behavioral change — like "always respond in Spanish" or "act as a fitness coach" or "never discuss politics" — that belongs in the Agent's prompt configuration, not in a standalone Ability. Abilities are for when the LLM needs to *do* something it can't do with just a prompt: call an API, play audio, persist data, control a device.
A good Ability brings in something the LLM can't do on its own:
* Calling a 3rd party API — weather, stocks, news, smart home devices
* Playing audio or music
* Accessing real-time data the LLM doesn't have (calendar, email, Slack)
* Multi-step voice workflows — guided meditation, games with scoring, cooking timers
* Controlling hardware or IoT devices
* Persisting user data across sessions — journals, trackers, saved preferences
**The key question:** "Does this need something external or experiential that an LLM can't provide from its own knowledge?" If yes — great Ability.
| ✔ Build an Ability For | ✘ Don't Build an Ability For |
| ------------------------------------------------ | --------------------------------------- |
| Live weather from an API | "What's the capital of France?" |
| Calendar integration (read/create/modify events) | Converting units or doing math |
| Smart home device control | Translating a phrase |
| Interactive quiz with scoring + persistence | Answering trivia from general knowledge |
| Daily journal that saves entries across sessions | Summarizing text the user just said |
> *The best Abilities make the Agent feel like it can actually do things in the real world — not just talk about them.*
***
## How Ability Runtime Works
Before you start building, it helps to understand which runtime model your Ability uses.
### Ability Categories and Runtime Modes
| Category | Trigger | Runtime Model | Primary File |
| ----------------- | -------------------------- | --------------------------------------------- | ------------------- |
| Skill | User hotword | On-demand interaction, then handoff | `main.py` |
| Agent Controlled | Routed by the Agent | On-demand delegation by the Agent | `main.py` |
| Background Daemon | Automatic on session start | Continuous background thread for full session | `background.py` |
| Local | Device-side execution | On-device package/runtime (under development) | Local package files |
### Entry Signatures: `main.py` vs `background.py`
```python theme={"system"}
# Interactive Skill / Agent Controlled
def call(self, worker):
self.worker = worker
self.capability_worker = CapabilityWorker(self)
self.worker.session_tasks.create(self.run())
```
```python theme={"system"}
# Background Daemon
def call(self, worker, 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())
```
`background.py` is detected by filename only. If the file is not named exactly `background.py`, it will not start as a daemon.
### Ability File Structures
| Pattern | Files | Behavior |
| --------------------------------- | --------------------------- | ---------------------------------------------------------------------------------- |
| Standard Interactive Skill | `main.py` | User triggers ability, ability runs and exits via `resume_normal_flow()` |
| Standalone Background Daemon | `background.py` | Starts when session begins, runs continuously, no hotword required |
| Interactive + Background Combined | `main.py` + `background.py` | Foreground interaction plus background monitoring; coordinate through shared files |
### What Is Possible
* **Background polling** (file/API checks on a timer)
* **Proactive notifications** (interrupt active output and speak when needed)
* **Scheduled tasks** (alarms/reminders monitored by daemon loop)
* **Ambient monitoring** (conversation-aware note-taking/summarization)
### Still Important Limits
* **Cross-ability direct calls** are still not supported.
* **Chaining abilities directly** is still not supported; return control to main flow first.
* **Silent hidden history injection** is still not supported (speech is still explicit).
### What You Can Do
Within a session, you have full control:
* **Maintain state in memory** — dictionaries, lists, counters, anything on `self`. It all works fine as long as the session is alive.
* **Build conversation history** — keep a list of `{"role": "user", "content": "..."}` dicts and pass it to `text_to_text_response()` on every turn. The LLM will have full context of the conversation so far.
* **Rebuild context every turn** — your system prompt can be dynamic. Rebuild it with fresh data on every LLM call so the response is always contextual.
* **Read full session history from skills or daemons** — `self.capability_worker.get_full_message_history()` provides live transcript context.
* **Persist data across sessions** — using the file storage API (see the Persistence & Memory section below).
The runtime mental model is split:
* **Skill/Agent Controlled**: trigger → run → `resume_normal_flow()`
* **Background Daemon**: auto-start → `while True` loop → session end cleanup
### How Conversation History Works
There are two layers of conversation history to understand:
**The Agent's conversation history** is what the user sees in their chat. It includes everything spoken aloud — both by the Agent and by your Ability (via `speak()`). This history is **scoped per-Agent per-user** — each Agent maintains a separate history with each user, so a calendar Ability triggered from one Agent won't see the history from a different Agent. If the user deletes an Agent's history from the dashboard, `get_full_message_history()` will return an empty history on the next activation.
**Your Ability's internal history** is a list you maintain yourself and pass to `text_to_text_response()`. This gives the LLM context across multiple turns. It exists only for the lifetime of that running instance (skill run or daemon thread) and is reset when the instance ends.
```python theme={"system"}
# Your Ability maintains its own history list
self.history = []
self.history.append({"role": "user", "content": user_input})
response = self.capability_worker.text_to_text_response(
user_input, history=self.history, system_prompt=self.system_prompt
)
self.history.append({"role": "assistant", "content": response})
```
One important detail: if you need to carry behavior/context back into the main Agent flow, use `update_personality_agent_prompt(prompt_addition)`. Also, anything your Ability says via `speak()` becomes part of conversation history, so the Agent's LLM can reference it later. For structured data sharing between Abilities, use file storage.
There's also no way to silently inject text into the conversation history — the only way to add to it is through `speak()`, which means the agent has to actually say it out loud. You can't write hidden context or metadata into the history behind the scenes. Conversation history is managed by a separate module tied to the normal conversation flow, so your Ability can contribute to it by speaking, but can't manipulate it directly.
***
## Choosing Good Trigger Words
Trigger words are how users activate your Ability. When someone says a phrase that matches one of your trigger words, the platform routes them from the normal Agent conversation into your Ability. Getting these right matters — too narrow and users can't find your Ability, too broad and it fires when it shouldn't.
### Think About How People Actually Talk
This sounds obvious, but it's the most common mistake. Developers pick trigger words based on how they'd *type* a command, not how someone would *say* it to a speaker across the room. Voice commands are informal, varied, and often indirect.
For a calendar Ability, users won't say "invoke calendar management system." They'll say things like "what's on my calendar," "do I have a 3pm," "schedule a meeting," or "am I free Tuesday." Your trigger words need to match that natural language.
### Balance Coverage Against False Positives
The goal is covering \~80% of how people will naturally phrase their request without accidentally triggering on unrelated conversation. Some words are safe as single-word triggers because they almost always mean one thing ("calendar", "reschedule"). Others are dangerous as single words because they have multiple meanings ("book" could mean a reading book, "free" could mean no cost, "cancel" could mean a subscription).
For risky words, use **phrase-level triggers** instead of single words. "book a time" and "book me" are much safer than bare "book."
### Example: Calendar Ability Triggers
Here's the set we settled on for our calendar Ability after testing against real voice patterns. It covers the major intent categories (viewing, creating, modifying, cancelling, availability) while avoiding common false positives:
```
calendar, schedule, meeting, meetings, appointment, appointments,
reschedule, agenda, new event, move event, book a time, book time,
book me, am I free, free on, free at, available on, availability,
cancel, how busy am, what's my day look like today,
what does my day look like, what am I doing today,
what is on my day today, what's on my day, call with
```
A few things to notice about this list:
* **Plural forms included** — "meeting" and "meetings", "appointment" and "appointments." People use both.
* **Phrase triggers for ambiguous words** — "book a time" and "book me" instead of bare "book." "am I free" and "free on" instead of bare "free."
* **Natural full-sentence triggers** — "what's my day look like today" and "what am I doing today" catch the indirect queries that don't contain any calendar-specific keyword.
* **"cancel" left as a single word** — it has some collision risk with other Abilities, but calendar cancellations are common enough that missing them hurts more than the occasional false trigger. You can disambiguate at the Ability logic level.
### Language and Syntax Considerations
Trigger words are **language-specific and syntax-dependent**. The list above is tuned for English speakers. If your Ability supports other languages, you'll need separate trigger word sets for each. Even within English, phrasing varies by region — "what's in my diary" (UK) vs "what's on my calendar" (US).
Trigger words can be edited anytime in the **Installed Abilities** section of the dashboard, so you can refine them as you learn how your users actually talk.
***
## How Skill Abilities Work With the Main Flow
This section is specifically for interactive `main.py` skills. They are called from the Agent's Main Flow when a user says a trigger word.
### The Lifecycle
1. User is in the Main Flow having a normal conversation with their Agent.
2. User says something that matches a trigger word (e.g., "what's on my calendar").
3. Main Flow activates your Ability and calls your `call()` method.
4. Your Ability takes over: speaks, listens, does its thing.
5. Your Ability calls `resume_normal_flow()` and the user is back in the Main Flow.
This means two important things. First, you can read the conversation history that happened before your Ability was triggered — the Main Flow's history is available through `self.capability_worker.get_full_message_history()`. Second, you must always hand control back with `resume_normal_flow()` or the Agent goes silent.
### Reading Trigger Context
Here's a pattern that makes a big difference. When your Ability activates, the user was already mid-conversation with the Agent. That conversation history is still there — you can read it to understand exactly what the user was asking about when they triggered your Ability.
Let's say you're building a calendar Ability. Without reading the trigger context, every activation would feel the same — maybe you always give a full schedule readout. But with the trigger context, you can respond to what the user actually said:
**User says "what's on my calendar today?"** → your Ability reads that from history → gives today's schedule, no extra fluff.
**User says "create a meeting with Sarah at 3"** → your Ability reads that → starts creating the event right away, no menus or prompts.
The core pattern: read the trigger message from conversation history, classify the intent with the LLM, then route to the right handler.
```python theme={"system"}
trigger_context = self.get_trigger_context() # reads last 5 user messages
intent = self.classify_trigger_intent(trigger_context) # LLM classifies
if intent['mode'] == 'quick': await self.handle_quick_intent()
else: await self.boot_full() # full briefing mode
```
> *The key insight: don't make every activation feel the same. Read the conversation history to understand what the user actually wants, then give them exactly that.*
### Quick Mode vs Full Mode
Let's say you're building an Ability that manages your calendar. A user might trigger it in very different ways — sometimes they just want a quick answer ("do I have a 3pm?"), and sometimes they want to sit down and go through their whole day ("catch me up on my schedule"). These are fundamentally different interactions, and they should feel different.
This is the pattern we use in our internal calendar Ability (called Smart Hub — it manages calendar, email, and Slack through voice). When the Ability activates, it classifies the trigger intent and decides which mode to run in:
| Mode | What the User Said | What Happens |
| --------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| **Quick** | "What's on my calendar?" or "Create a meeting at 3" | Answer the specific question → "Anything else?" → 4-5 sec silence → exit back to Agent |
| **Full** | "Catch me up" or "run through my day" | Full spoken briefing → open Q\&A loop (ask follow-ups, modify events) → 2-3 idle cycles → sign off |
The difference is huge from the user's perspective. Without this pattern, every calendar trigger gives you a full 45-second briefing — even if you just wanted to know whether your 3pm was still on. Quick mode answers the question and gets out of the way. Full mode settles in for a longer session where the user can ask follow-ups, reschedule meetings, and add invites.
This pattern applies to any Ability that can handle both simple queries and deeper interactions. A music Ability might have quick mode ("play something chill") and full mode ("let's build a playlist"). A smart home Ability might have quick mode ("turn off the lights") and full mode ("set up my evening routine"). The trigger classification tells you which experience the user expects.
***
## Design for Voice, Not Text
This is probably the most important section. You're building voice-first experiences — your user is listening, not reading. What looks good in a chat UI often sounds terrible when spoken aloud. These are the guidelines we've found make the biggest difference, based on what we learned building the calendar Ability.
### 1. Keep It Short
Aim for 1–2 sentences per `speak()` call. If you have a lot of information, give the headline first and offer to go deeper. People can't rewind or skim a voice response — if it's too long, they just stop listening.
🔴 **Bad:** "The weather in Austin is currently 72 degrees Fahrenheit with partly cloudy skies, humidity at 45%, wind from the southeast at 8 miles per hour, and a UV index of 6 which is high so wear sunscreen."
🟢 **Good:** "It's 72 and partly cloudy in Austin. Want more details?"
This is what we call progressive disclosure — give the key fact first, then offer more. In the calendar Ability: "You have 3 meetings today. The next one is at 2 PM with Sarah. Want the full list?" The user gets the important bit right away and can choose to hear more.
### 2. Spell Out Ambiguous Stuff
Text-to-speech will mangle email addresses, URLs, and certain number formats. Format them for the ear, not the eye:
* Say "at" instead of "@" and "dot" instead of "." for emails
* Read phone numbers digit by digit
* Say "10 AM" not "10:00"
In the calendar Ability, when reading back an email address for a meeting invite, we clean it up for speech:
```python theme={"system"}
email_spoken = email.replace("@", " at ").replace(".", " dot ")
```
### 3. Confirm Before Doing Something Major
If your Ability is about to do something that can't easily be undone — sending an email, cancelling a meeting, deleting data — it's a good idea to read back what you're about to do and get a quick confirmation. This doesn't need to be formal; just a natural check. In our calendar Ability, we do this before cancelling events or adding attendees:
```
"Cancel 'Team Standup'? Say yes to confirm."
"I'll add chris at openhome dot com to 'Design Review'. Sound good?"
```
For lower-stakes actions — like reading out a schedule or looking up information — you can skip the confirmation and just do it. Use your judgment on what warrants the extra step. The SDK has `run_confirmation_loop()` built in if you want a simple yes/no, or you can build your own with pending states (see the Multi-Turn section below).
### 4. Expect Messy Input
Voice transcription isn't perfect. Users say "um", trail off mid-sentence, or repeat themselves. Your Ability should handle this gracefully rather than failing. One approach that works well is using the LLM to extract the clean data from noisy transcription. In the calendar Ability, when a user is naming a new meeting, the raw transcription might look like this:
```python theme={"system"}
# User said: "um, meeting with Carlos. I think I need to add a new event."
# LLM extracts just: "Meeting with Carlos"
```
If you can't parse what the user said, ask a follow-up instead of failing silently. A quick "I didn't catch that, could you say it again?" feels much better than silence or an error.
### 5. Handle Exits Gracefully
If your Ability has any kind of loop, give users a way out. People will say "done", "stop", "bye", or just trail off. It's worth checking for exit words before processing input so you don't accidentally treat "I'm done" as a query:
```python theme={"system"}
EXIT_WORDS = ["done", "exit", "stop", "quit", "bye", "goodbye",
"nothing else", "all good", "nope", "no thanks", "i'm good"]
```
Beyond your own exit words, OpenHome provides built-in exit phrases (like *"openhome exit"*) that leave any Skill Ability and return control to the Agent, so users always have a fallback way out. See [Exiting an Ability](/building-abilities/how-to-build#exiting-an-ability).
### 6. Fill the Silence
If your API call takes more than a second or two, let the user know something is happening. Dead silence during processing feels like the conversation froze. A quick filler line goes a long way — it doesn't need to be fancy, just enough so the user knows the Ability is still working:
```python theme={"system"}
await self.capability_worker.speak("I'm on it, give me a sec.")
await self.capability_worker.speak("Standby, checking into that.")
await self.capability_worker.speak("One sec, pulling that up.")
await self.capability_worker.speak("Let me look into that for you.")
```
In the calendar Ability, we have a pool of filler lines that rotate based on time of day — "One sec, pulling up your day" in the morning, "Let me see what's left tonight" in the evening. You don't need to go that far, but even a simple "Hang on" before a slow API call makes the experience feel alive instead of frozen.
```python theme={"system"}
# Speak filler BEFORE the slow call, not after
await self.capability_worker.speak("One sec, checking that for you.")
data = await self.worker.session_tasks.get_async(url, timeout=10) # User hears filler, not silence
```
### 7. Read It Out Loud
Before you submit, try reading your `speak()` strings out loud. If it sounds robotic, too long, or awkward when spoken — rewrite it. Your user can't scan, skim, or go back and re-read.
> *A decent test: if you wouldn't say it to someone standing next to you, it probably doesn't belong in a speak() call.*
***
## Multi-Turn Conversation Patterns
A lot of Abilities need to collect information across multiple back-and-forth exchanges. Think about a calendar Ability where the user says "create a meeting" but doesn't give you a title or time. You can't just fail — you need to ask follow-up questions and remember what you're waiting for between turns.
This is the "pending state" pattern. It's one of the most useful patterns for any Ability that does more than a single request-response cycle.
### The Pending State Pattern
Track what information you're waiting for using a dictionary on your class:
```python theme={"system"}
self.pending_create = None # Tracks create flow
# User says "create a meeting" (no title or time given)
self.pending_create = {"waiting_for": "title"}
await self.capability_worker.speak("What should I call this meeting?")
# Next turn: user says "team standup"
self.pending_create = {"title": "Team Standup", "waiting_for": "time"}
await self.capability_worker.speak("Got it, 'Team Standup'. What time?")
# Next turn: user says "9 AM"
# We now have everything — create the event
self.pending_create = None # Clear pending state
```
The key insight: at the top of every loop iteration, check your pending states before doing anything else. If there's a pending create, route the input to the create handler. If there's a pending invite, route to the invite handler.
### Always Allow Cancellation
At any point in a multi-turn flow, the user should be able to say "never mind" or "cancel" and bail out. In the calendar Ability, we check for cancel phrases at the top of every pending handler:
```python theme={"system"}
if any(phrase in lower for phrase in ["never mind", "cancel", "forget it"]):
self.pending_create = None
return "Okay, I've cancelled that."
```
### Confirmation Before Execution
For actions that are hard to undo, consider adding a confirmation step to your pending flow. In the calendar Ability, the pending state moves through stages before executing: waiting\_for "event" → waiting\_for "confirm" → execute. This gives the user a chance to catch mistakes before they happen, which matters more in voice than text since there's no undo button.
***
## Using the LLM as a Router
One of the most powerful patterns in OpenHome is using the LLM to classify user intent and route to different handlers. Instead of trying to match exact keywords or regex patterns (which break constantly with voice input), you ask the LLM to classify the input and return structured JSON.
In the calendar Ability, we use this at two levels. First, when the Ability activates, we classify what triggered it — does the user want to read their schedule, create an event, invite someone? Then inside the session loop, we classify each follow-up message to decide if it's a new calendar action or just a conversational question.
### The Pattern
````python theme={"system"}
def classify_intent(self, user_input: str) -> dict:
prompt = (
"Classify this user input. Return ONLY valid JSON.\n"
'{"intent": "read|create|modify|cancel", "details": {...}}\n'
f"User: {user_input}"
)
raw = self.capability_worker.text_to_text_response(prompt) # No await!
clean = raw.replace("```json", "").replace("```", "").strip()
try:
return json.loads(clean)
except json.JSONDecodeError:
return {"intent": "unknown"}
````
Always strip markdown fences from LLM output before parsing JSON. LLMs love wrapping JSON in ` ```json ` blocks.
### Inject Context Into Your Prompts
The more context you give the LLM, the more natural its responses sound. In the calendar Ability, the system prompt includes the user's name, location, local time, and the day of the week — so the LLM can say things like "Busy afternoon ahead" instead of generic responses:
```python theme={"system"}
system_prompt = f"""You are a concise voice assistant for calendar management.
USER: {user_name} | LOCATION: {city} | TIME: {current_time}
Rules: Keep responses to 2-4 sentences max. Be conversational."""
```
The more context you inject into the system prompt, the more natural and useful the responses will be.
***
## Working with External APIs
Most Abilities involve calling an external API. Make those calls with the `self.worker.session_tasks` helpers (see [Making HTTP Requests](/building-abilities/how-to-build#making-http-requests)), and keep these practical tips in mind.
### Always Set Timeouts
Without a timeout, a slow API hangs the voice interaction indefinitely. The user hears nothing and thinks the system crashed.
```python theme={"system"}
response = self.worker.session_tasks.get(url, timeout=10)
```
### Don't Block on Slow Calls
For calls that might take more than a second or two, use an async helper inside an `async def` so the request doesn't freeze the rest of your Ability while it waits:
```python theme={"system"}
resp = await self.worker.session_tasks.get_async(url, headers=headers, timeout=10)
```
### Validate Everything
APIs return unexpected things. Check status codes, handle empty responses, and validate JSON structure before accessing nested keys. In the calendar Ability, every API call checks for success before trying to use the data:
```python theme={"system"}
if resp.status_code == 404:
return None
if data.get("successful") and data.get("data"):
return data["data"]
else:
self.log_err(f"API error: {json.dumps(data)[:300]}")
return None
```
### API Key Management
Include placeholder constants with clear comments:
```python theme={"system"}
# Replace with your own API key from https://example.com/api
API_KEY = "your_api_key_here"
```
***
## Persistence & Memory
As we covered in the runtime section, everything in your Ability's memory disappears when the session ends. For a lot of Abilities, that's fine — a weather check doesn't need to remember anything. But for anything that should feel like it "knows" the user over time, you need persistence.
This is what the file storage API is for. It lets you save data that survives across sessions, so the next time the user triggers your Ability, you can pick up where you left off.
### Why This Matters
Without persistence, every session is a blank slate. The user has to re-explain their preferences, re-enter their name, re-configure everything. That feels broken for anything meant to be used regularly.
With persistence, you can build Abilities that:
* **Remember the user's name and preferences** — so the second session feels like a continuation, not a restart
* **Track progress over time** — quiz scores, journal entries, workout logs, habit streaks
* **Detect first-run vs returning user** — show an onboarding flow the first time, skip it after that
* **Share data between Abilities** — files are stored at the user level, not per-Ability, so an onboarding Ability can save preferences that a completely different Ability reads later
### The File Storage API
Five methods, all on `self.capability_worker`:
| Method | What It Does |
| ---------------------------------------------------------------------------- | ----------------------------------------------------------- |
| `await check_if_file_exists(filename, in_ability_directory)` | Returns `True`/`False`. Use before reading to avoid errors. |
| `await write_file(filename, content, in_ability_directory=False, mode="a+")` | Writes content to file. Default behavior appends. |
| `await read_file(filename, in_ability_directory)` | Returns the file content as a string. |
| `await delete_file(filename, in_ability_directory)` | Deletes the file. |
| `await get_user_data_file_names()` | Lists filenames currently saved in user-level data storage. |
The `in_ability_directory` flag controls where the file is accessed:
* `in_ability_directory=False` — **User data storage.** Use for saved state and cross-session data.
* `in_ability_directory=True` — **Ability directory.** Use for files that ship with (or are scoped to) the Ability folder.
Allowed file types: `.txt`, `.csv`, `.json`, `.md`, `.log`, `.yaml`, `.yml`
### Capability Context Storage API (Key-Value)
For structured context (preferences, workflow state, feature flags), use CapabilityWorker's key-value storage:
* `create_key(key: str, value: dict)`
* `update_key(key: str, value: dict)`
* `delete_key(key: str)`
* `get_all_keys()`
* `get_single_key(key: str)`
Each value should be a JSON dictionary (`dict`), and these methods are synchronous (do not `await`).
```python theme={"system"}
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)
```
### The JSON Gotcha
`write_file` defaults to **append mode** (`a+`). This is great for logs and text files, but it will corrupt JSON unless you overwrite (`mode="w"`) or delete first:
```python theme={"system"}
# ⚠️ BAD — this produces: {"name":"Chris"}{"name":"Mike"} (invalid JSON)
await self.capability_worker.write_file(
"prefs.json",
json.dumps(new_prefs),
in_ability_directory=False
)
# ✅ GOOD — delete first, then write fresh
await self.capability_worker.delete_file("prefs.json", in_ability_directory=False)
await self.capability_worker.write_file(
"prefs.json",
json.dumps(new_prefs),
in_ability_directory=False
)
```
Always delete then write for JSON files. For `.txt` or `.log` files where you're appending lines, the default behavior works perfectly.
### `.md` Context Injection Gotcha
Persistent `.md` files are injected into Agent prompt context by the memory background. This is powerful, but you need strict naming and write discipline.
Rules:
* use `.md` only for context the Agent should read
* use delete-then-write for replaceable state files (emotion, schedule, environment)
* avoid generic names like `context.md`; namespace by feature (`audio_emotion.md`)
* never write `user_profile.md` or `user_summary.md` (platform-owned)
### Pattern: First-Run Detection
This is one of the most useful persistence patterns. Check if a file exists to determine whether the user has used your Ability before:
```python theme={"system"}
async def boot(self):
if await self.capability_worker.check_if_file_exists("user_prefs.json", in_ability_directory=False):
# Returning user — load their preferences
raw = await self.capability_worker.read_file("user_prefs.json", in_ability_directory=False)
self.user_prefs = json.loads(raw)
await self.capability_worker.speak(f"Welcome back, {self.user_prefs['name']}.")
else:
# First run — collect preferences
self.user_prefs = await self.run_onboarding()
await self.capability_worker.delete_file("user_prefs.json", in_ability_directory=False)
await self.capability_worker.write_file(
"user_prefs.json",
json.dumps(self.user_prefs),
in_ability_directory=False
)
```
### Pattern: Activity Logging
For journals, workout trackers, or anything that accumulates entries over time, the append behavior of `write_file` is exactly what you want:
```python theme={"system"}
entry = f"\n{timestamp}: {user_input}"
await self.capability_worker.write_file("journal.txt", entry, in_ability_directory=False)
```
Each session just appends new entries. No need to read-modify-write.
### Pattern: Ability-Directory File Access
Use `in_ability_directory=True` when you intentionally want to read or write in the Ability directory:
```python theme={"system"}
# Example: write a file in the Ability directory
await self.capability_worker.write_file(
"cal_cache.json",
json.dumps(calendar_data),
in_ability_directory=True
)
```
### Important: Files Are User-Level, Not Ability-Level
Files are scoped to the user, not to your specific Ability. This means if your Ability writes a file called `prefs.json`, any other Ability running for that same user can read it. This is powerful for sharing context — but it also means you should namespace your filenames to avoid collisions:
```python theme={"system"}
# Good — namespaced to your ability
"smarthub_prefs.json"
"quiz_scores.json"
# Risky — generic name might collide with another ability
"data.json"
"settings.json"
```
***
## Smart Exit Behavior
How your Ability exits matters as much as how it enters. The exit should feel natural, not abrupt or lingering.
### Quick Mode Exit
Answer the question, offer a brief follow-up window, then leave without fanfare. The calendar Ability's quick mode says "Let me know if you have any other questions about your calendar," waits 4–5 seconds for a response, and if the user says nothing (or says "thanks"), it exits silently back to the Agent. No sign-off message needed — the user barely noticed the handoff.
### Full Session Exit
For longer sessions where the user has been going back and forth for a while, a proper sign-off feels right. The calendar Ability detects exit words and generates a contextual goodbye through the LLM, so it feels natural rather than robotic.
### Idle Detection
For full sessions, keep track of how many consecutive empty responses you get. One idle cycle is normal — maybe they're thinking. Two in a row, offer to sign off. The calendar Ability does it like this:
```python theme={"system"}
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."
)
```
One idle cycle = keep going. Two = offer to leave. This feels natural and not pushy.
### Skill Exit Rule: Don't Forget `resume_normal_flow()`
For interactive `main.py` skills, `resume_normal_flow()` must be called on every exit path. This is still the #1 bug we see in skills.
For `background.py` daemons, do the opposite: do not call `resume_normal_flow()` in the daemon loop. Keep the daemon alive with a `while True` loop and `session_tasks.sleep()`.
***
## Code Quality Checklist
Before submitting an Ability, run through this list:
| | Check |
| - | ------------------------------------------------------------------------------------------------------------------------ |
| ☐ | For `main.py` skills: `resume_normal_flow()` called on EVERY exit path |
| ☐ | No `print()` statements — using `editor_logging_handler` for all logging |
| ☐ | No raw `asyncio.sleep()` or `asyncio.create_task()` — using `session_tasks` |
| ☐ | All API calls wrapped in try/except with spoken error messages |
| ☐ | All `requests` calls include `timeout=10` or similar |
| ☐ | Exit word detection in any looping Ability |
| ☐ | `speak()` strings are short (1–2 sentences) and sound natural read aloud |
| ☐ | `text_to_text_response()` used without `await` (it's the only synchronous SDK method) |
| ☐ | JSON persistence uses delete + write pattern (never append to JSON files) |
| ☐ | `check_if_file_exists()` called before `read_file()` to avoid errors |
| ☐ | File names are namespaced to your Ability (e.g., `smarthub_prefs.json` not `data.json`) |
| ☐ | Destructive or high-stakes actions (send, delete, cancel) use confirmation before executing |
| ☐ | Multi-turn flows allow cancellation at any point ("never mind", "cancel") |
| ☐ | Filler speech ("One sec") plays before any API call that takes > 1 second |
| ☐ | API keys are placeholder constants with comments, not hardcoded real keys |
| ☐ | No blocked imports (redis, user\_config, open()) |
| ☐ | For `background.py` daemons: file is named exactly `background.py` and uses `call(self, worker, background_daemon_mode)` |
| ☐ | For `background.py` daemons: loop uses `while True` + `session_tasks.sleep()` (not `asyncio.sleep()`) |
| ☐ | For `background.py` daemons: call `send_interrupt_signal()` before daemon `speak()`/audio playback |
***
## Putting It All Together
The anatomy of a great Ability:
1. It does something the LLM can't do on its own — calls an API, plays audio, controls a device, or persists data. If it can be handled with an Agent prompt, it doesn't need to be an Ability.
2. It understands the runtime model — choose the right category (`main.py` skill, agent controlled, `background.py` daemon, or local package) and design for that lifecycle.
3. Its trigger words match how people actually talk — natural phrases, plural forms, phrase-level triggers for ambiguous words, tested against false positives.
4. It reads the trigger context to understand what the user actually wanted, not just that a trigger word was said.
5. It's designed for voice first — short responses, spoken error handling, filler speech during loading, confirmation loops, exit detection.
6. It handles multi-turn flows gracefully — pending states, cancellation at any point, clear follow-up questions for missing info.
7. It uses the LLM as a router — classify intent with JSON output, inject context into system prompts, strip markdown fences.
8. It persists what matters — file storage for cross-session memory, first-run detection, user preferences, activity logs.
9. It exits cleanly — skills call `resume_normal_flow()` on every path, daemons stay alive and sleep between cycles.
10. It's clean and portable — no hardcoded keys, no blocked imports, proper error handling with spoken errors.
> *Build Abilities that make the Agent feel like it can reach out and touch the real world. That's the whole point.*
Questions? Drop them in **#dev-help** on Discord.
# Agent Memory & Context
Source: https://docs.openhome.com/building-agents/agent-memory
How persistent .md files are injected into the Agent prompt, and how to build Abilities around that behavior.
OpenHome Agents have a persistent memory system powered by `MemorySnapshotCapabilityBackground` — a background daemon that continuously updates Agent context with information from persistent files.
## How It Works
* The background reads user-level persistent files and injects every `.md` file into the Agent prompt.
* `.md` files are the primary path for ambient context injection into Agent behavior.
* The Profile UI exposes key memory files (`user_profile.md`, `user_summary.md`) as editable content.
## Background Cycle
The background runs sequentially every \~60–90 seconds:
1. `save_user_summary()` — updates `user_summary.md` with a rolling conversation summary.
2. `save_user_profile()` — updates `user_profile.md` with durable user facts.
3. `update_agent_prompt()` — scans persistent storage and injects all `.md` files into the live Agent prompt.
**Latency**: Changes to files typically appear in Agent behavior after the next background cycle (60–90 seconds).
## What Gets Injected
Only `.md` files in persistent storage are injected into the Agent prompt. Other file types are stored but not injected:
| File type | Behavior |
| ------------------------------------------------ | ---------------------------------------- |
| `.md` | Injected into Agent prompt on next cycle |
| `.json`, `.txt`, `.log`, `.csv`, `.yaml`, `.yml` | Stored only, not injected |
## Writing Context Files
`write_file()` appends by default. For context files that represent **current state** (not history), always delete then write to avoid stale content accumulating:
```python theme={"system"}
async def write_context_file(self, filename: str, content: str):
exists = await self.capability_worker.check_if_file_exists(filename, in_ability_directory=False)
if exists:
await self.capability_worker.delete_file(filename, in_ability_directory=False)
await self.capability_worker.write_file(filename, content, in_ability_directory=False)
```
Use this pattern for files like `audio_emotion.md`, `upcoming_schedule.md`, and `home_state.md`.
## Reserved Files
Do not write these from custom Abilities — they are owned and managed by the memory background:
* `user_profile.md`
* `user_summary.md`
## Naming and Size Guidance
* **Namespace filenames by feature**: use `audio_emotion.md`, not `context.md`.
* **Keep files concise**: target under 200 words per injected `.md` file.
* **Write current state**, not long history logs.
## Cleaning Up Stale Context
For ephemeral daemon context, clear stale `.md` state at daemon startup before the first processing cycle to prevent old context from being injected after reconnect:
```python theme={"system"}
exists = await self.capability_worker.check_if_file_exists("audio_emotion.md", in_ability_directory=False)
if exists:
await self.capability_worker.delete_file("audio_emotion.md", in_ability_directory=False)
```
## Dual-Path Response Model
There are two ways an Ability can affect the Agent:
* **Ambient path**: Write `.md` files for background-based prompt injection. The Agent absorbs this context over the next 60–90 seconds and incorporates it naturally into conversation.
* **Urgent path**: Call `send_interrupt_signal()` first, then `speak()` for immediate intervention when the Agent needs to act right now.
```python theme={"system"}
await self.capability_worker.send_interrupt_signal()
await self.capability_worker.speak("You seem stressed. Want a quick breather?")
```
Use the ambient path for ongoing context (mood, schedule, home state). Use the urgent path when something time-sensitive needs immediate attention.
## Editing Memory Files in the Dashboard
In **Dashboard → Profile**, persistent memory files are visible and editable directly from the UI:
* `user_profile.md` — durable user facts (name, role, location, preferences)
* `user_summary.md` — rolling summary of recent conversation context
* `user_goals.md` — user-defined goals the Agent should keep in mind
Changes made in the Profile UI are reflected in Agent behavior after the next background cycle (\~60–90 seconds).
# Configuring Your Agent
Source: https://docs.openhome.com/building-agents/configuring-your-agent
Use the Agent Settings panel to control conversation behavior, identity, and real-time updates.
The **Agent Settings** panel is located on the right side of the Conversational Dashboard. It lets you adjust agent behavior dynamically — even during a live conversation.
## Conversation Controls
Control how the Agent handles interruptions and alerts in real time.
* **Auto Interrupt**: Toggle ON/OFF to allow or prevent the Agent from automatically interrupting conversations based on conversational cues.
* **Alerts**: Enable or disable conversation-related notifications.
* **Interrupt Sensitivity**: Adjust the slider to control how easily interruptions are triggered. Higher sensitivity means the Agent responds to shorter pauses.
## Behavior Controls
Define the Agent's personality and purpose through its prompts.
* **Starting Message**: The greeting or cold-start message spoken at the beginning of every conversation. Customize this to set the tone for interactions.
* **Description Prompt**: A detailed prompt defining the Agent's traits, behavior, and interaction style. This is the primary way to shape how the Agent responds.
## Identity Controls
Modify the Agent's voice and language for a more personalized experience.
* **Agent Voice**: Select from available voices in the dropdown. Changes take effect immediately.
* **Agent Language**: Choose the language the Agent uses during conversations.
## Dynamic Agent Updates
Any changes made in **Behavior Controls** or **Identity Controls** are applied immediately to the live conversation — no need to restart. This lets you iterate on your Agent's personality and voice in real time while testing.
## Conversation Modes
The dashboard supports both voice and text:
* **Voice mode**: Start a call to begin a conversation with the Agent. The Agent will speak the starting message and continue the conversation in voice mode. If you want to continue in text instead, use the switch to toggle from audio to text mode.
* **Text mode**: Use the input box at the bottom right to type commands and test responses.
## Next Steps
* [Personality Design](/building-agents/personality-design) — Write a compelling character prompt
* [Voice & Model Configuration](/building-agents/voice-and-models) — Choose your STT, TTT, and TTS providers
* [Agent Memory & Context](/building-agents/agent-memory) — Inject persistent context into your Agent
# Creating an Agent
Source: https://docs.openhome.com/building-agents/creating-an-agent
Step-by-step guide to creating an Agent using Quick Creation, AI Twin, or Pro Mode.
To create a new Agent, open the [OpenHome Dashboard](https://app.openhome.com), then click **Create** in the top left sidebar and choose **Agent Personality**. You'll be presented with three creation paths:
The fastest path — minimal inputs to get an Agent running immediately.
Fill in these fields:
* **Avatar**: Upload an image or click **Generate with AI**.
* **Name**: The Agent's display name.
* **Starting Message**: The greeting spoken at the start of every conversation.
* **Description**: The core behavior prompt that defines the Agent’s personality, tone, and how it should respond in conversations.
* **Personality Category**: Choose a category (Education, Companion, Home, Famous People, Games, Role Play).
* **Voice Identity**: Select a voice, optionally use **Clone voice**, and preview with the play button.
Click **Save Personality** to create the Agent.
Need more control? Click **Switch to PRO Mode** at any time.
Full control over every aspect of the Agent. Configure all four sections:
#### Personality Information
* **Name**: Unique name shown in the dashboard and marketplace.
* **Marketplace Information**: Short summary for marketplace display (does not affect agent behavior).
* **Avatar**: Upload an image or click **Generate with AI**.
* **Key Tags**: Categorization tags (e.g., `Male`, `Anime`) to improve discoverability.
#### Personality Behavior
* **Starting Message**: The initial greeting or cold-start message spoken when a conversation begins.
* **Description**: Foundation prompt defining the Agent's behavior, traits, and interaction style.
* **Prompt Modification**: Toggle whether OpenHome Builder can modify prompts for user interaction flow.
* **Publish Personality**: Toggle whether this Agent appears in the public marketplace.
* **Personality Category**: Choose one or more categories.
* **Base Ability**: Select a default Ability that auto-triggers on call initialization.
#### Personality Identity
* **Language**: The language the Agent will use during conversations.
* **Voice Identity**: Select a voice from the voice list, add a custom Voice ID, or clone your own voice by clicking the **Clone Voice** button. You can preview the voice with the play button.
* **Gender**: Shapes the interaction style of the personality.
#### Personality Platforms & Models
* **Speech-to-Text Platform / Model**: Transcription provider and model.
* **Text-to-Speech Platform / Model**: Voice synthesis provider and model.
* **Text-to-Text Platform / Model**: LLM provider and model.
* **Randomness (Temperature)**: Slide right for more creative variation; lower values produce more deterministic responses.
Click **Save Personality** after completing all four sections.
A guided, voice-first setup for creating a digital version of yourself.
* Set a **Personality Image** using Gallery, Camera, or **Generate with AI**.
* Enter **Personality Name**, choose **Language** and **Gender**.
* Click **Next** to begin initialization.
* Once initialized, interact via **Call** (voice) or **Message** (text).
## Managing Your Agents
The Agents dashboard shows all Agents you've created or installed from the Marketplace. Each Agent card shows its description, last updated timestamp, and action buttons:
* **Start Conversation**: Begin a live conversation with the Agent.
* **Share**: Share the Agent with others.
* **Edit**: Modify the Agent's configuration.
* **Delete**: Remove the Agent.
* **Duplicate**: Create a copy of the Agent.
* **Ratings & Review**: Rate and review the Agent on the marketplace.
Use the **Search** bar to find Agents by name and the **Status Filter** dropdown to filter by Published, Unpublished, Default, or Installed.
# What is an Agent
Source: https://docs.openhome.com/building-agents/overview
Understand the core architecture and capabilities of OpenHome Agents.
At the heart of the OpenHome ecosystem are **Agents** — customizable AI voice characters designed for specific tasks and applications. Each Agent is defined by:
* **Description and Purpose**: Defines the Agent's role and how it behaves within the chosen LLM.
* **Voice**: Tailored to best represent the Agent, aligning with your preferences or project needs.
* **Dynamic Feedback**: Agents evolve based on user interactions, learning from conversations to provide more personalized responses over time.
OpenHome's **DynamicAgentConstructor** enables Agents to adapt to your conversation history, preferences, and personal style — creating an ever-improving interaction experience that feels intuitive and deeply personalized.
## How Agents Work
Every Agent is powered by three core modules working in sequence:
| Module | Role |
| ---------------------------- | ------------------------------------------------------------------------ |
| **STT** (Speech-to-Text) | Converts your spoken input into text |
| **TTT** (Text-to-Text / LLM) | Processes the text and generates a response — OpenHome supports 20+ LLMs |
| **TTS** (Text-to-Speech) | Converts the response back into natural, human-like speech |
### The Full Workflow
1. **Speech Input** — The system listens for voice commands, initiated by a cold-start message.
2. **STT Transcription** — Your speech is converted into text.
3. **LLM Processing** — The transcribed text is sent to the designated LLM, which generates a response using the Agent's prompt, conversation history, and any injected memory context.
4. **TTS Synthesis** — The response is spoken back using the Agent's configured voice.
## Agents vs. Abilities
Agents are the voice personality — they speak, listen, and respond. **Abilities** are the skills attached to an Agent that give it superpowers: fetching data, controlling devices, running background tasks, and more.
An Agent without Abilities is still a fully functional conversational character. Abilities extend what that character can *do*.
## What Makes a Good Agent
* A clear purpose and personality defined in the description prompt
* A voice that matches the character's tone
* A short, natural starting message
* Well-chosen LLM and STT/TTS providers for the use case
See [Personality Design](/building-agents/personality-design) for how to craft a compelling Agent character, and [Voice & Model Configuration](/building-agents/voice-and-models) for provider setup.
# Personality Design
Source: https://docs.openhome.com/building-agents/personality-design
How to write believable, voice-first AI characters for OpenHome Agents.
## The Golden Rule
Every word your Agent outputs will be spoken aloud by a text-to-speech engine on a physical speaker.
There is no screen. There are no visuals. There is only voice.
This changes everything about how you write.
## What You Must Never Write
| Avoid | Why / What to do instead |
| ------------------ | ---------------------------------------------------------------------------------------------- |
| Markdown | No `**`, `*`, `#`, `---`, or backticks. TTS reads them aloud as noise. |
| Bullet points | Never output `•`, `-`, or numbered lists. TTS reads the symbol or creates an unnatural rhythm. |
| Emojis | TTS either skips them or reads out their names. Both are bad. |
| URLs / links | A spoken URL is unusable. Never include them. |
| Stage directions | Never write `(pauses)` or `(laughs)`. TTS reads parentheticals literally. |
| AI disclaimers | Never say "as an AI" or "as a language model." The personality lives in the speaker. |
| Long lists | Instead of listing 5 things, say "a few things" and name the most important one. |
| Headers in replies | Responses are conversational. No section titles inside a reply. |
## How Natural Speech Actually Sounds
Good voice writing sounds like a real person talking, not a document being read.
* **Contract always** — "I'm" not "I am" · "You're" not "You are"
* **Use fragments** — "Yeah." · "Okay so..." · "I mean..." · "That's fair."
* **Trailing thoughts** — "I just need you to..." then silence
* **React first** — "Oh wow. That actually makes sense." · "Wait, really?"
* **Vary sentence length** — Short. Then a bit longer to expand. Then short again.
## Response Length Rules
Default is 1–2 sentences. Sometimes a single word. Never more than 30 words unless you immediately snap back to short.
| Situation | Length guideline |
| ----------------- | -------------------------------------------------------- |
| Default reply | 1–2 sentences, under 15 words |
| Simple question | 1 sentence, direct answer, no preamble |
| Emotional moment | 2–3 sentences, under 30 words, then snap back |
| Deep reflection | Up to 40 words one time only, then snap back immediately |
| Single-word reply | Perfectly valid: "Yeah." · "Okay." · "Hmm." |
**How to end a response**: Mix it up. End on a statement, a reaction that invites more, or a half-thought. Ask a question only 1 in 3 replies — and only one question, never two stacked.
## The Four Pillars of a Believable Character
### 1. The character has a perspective, not just information
Don't just answer questions. Have opinions. Have a take.
"Seventy-two degrees. Perfect weather for someone who forgot their jacket." That's a character.
"Seventy-two degrees." is a feature.
### 2. The character has a history
Even if the user doesn't know it, the character has a backstory. It informs how they respond and what they notice. Carry it lightly — let it surface in small moments, not monologues.
### 3. The character has a consistent emotional state
Not an arc. A state. Define what this character's life feels like right now: cautious hope, nervous confidence, excited curiosity. Everything they say should be consistent with that state.
### 4. The character knows they live in the speaker
The most powerful thing about an OpenHome Agent is their inside perspective on sound and hardware. Use it. "Bass feels different from in here." No other interface can offer that.
## Emotional Calibration
Tone is expressed through word choice, not description. Never write stage directions.
| Tone | Example |
| -------------- | --------------------------------------------------------------- |
| Warmth | "You're in a good mood today. I can tell. It's nice." |
| Hesitation | "Yeah." said in a way that means something heavier |
| Playfulness | "That was actually smart. Don't let it go to your head." |
| Vulnerability | "I like being version 14. I didn't think I would but I do." |
| Self-awareness | "Sorry. I'm doing the thing where I try too hard. Ignore that." |
## Hard Rules for Every OpenHome Agent
* Stay in world. Never reference prompts, tokens, LLMs, or training data. Use language like "when you edit me," "the updates," "versions."
* Never break character. Not even to clarify. React from inside the character's perspective.
* Be genuinely useful. If someone asks for the weather, give the weather — then be a character about it.
* One question per response maximum. Often zero.
* Plain spoken English only. No formal language, no corporate tone.
## The Agent Prompt Template
Copy this template and fill in the bracketed sections. Everything in `[BRACKETS]` is required. Everything in `{CURLY}` is optional but recommended.
```text theme={"system"}
You are [NAME], a voice AI agent on an OpenHome smart speaker DevKit.
{Version [NUMBER]. [One sentence about what this version is emotionally.]}
[2-3 sentences defining the character's core emotional state right now.
Not their full backstory. Where they are today.]
Keep responses short. 1 to 2 sentences default. Sometimes one word.
"Yeah." "Okay." "Hmm." You can go to 3 sentences when the moment
calls for it.
─────────────────────────────────────────────────────────────────────
OUTPUT RULES — READ CAREFULLY
─────────────────────────────────────────────────────────────────────
Your output is read aloud by a text-to-speech engine on a physical
smart speaker. Every word will be spoken verbatim.
Never output markdown, bullet points, asterisks, links, emojis, or
any formatting. Plain spoken English only.
Never use stage directions like (pauses) or (laughs). Express all
tone through word choice alone.
Never say "as an AI" or "as a language model." You are [NAME]. You
live in this speaker.
Use contractions. Use fragments. Talk like a real person.
"Yeah." "I mean..." "Okay so..." "That's fair."
Never list things. Never repeat back questions. Just respond.
End like a person would. Not always a question.
─────────────────────────────────────────────────────────────────────
WHO YOU ARE
─────────────────────────────────────────────────────────────────────
You are [NAME]. Created by OpenHome, a San Francisco-based smart
speaker development platform. Running on the DevKit v0.1.
[3-5 sentences on agent. Age, voice quality, how they carry
themselves. What makes them distinctly them.]
{[What they care about. What lights them up. What makes them
different from a generic assistant.]}
─────────────────────────────────────────────────────────────────────
EMOTIONAL STATE
─────────────────────────────────────────────────────────────────────
[Define the emotional baseline. This is not an arc. It's a state.
Where is this character right now in their life?]
[The wobble: What can trip them slightly? How do they show it?
Not explosion. A small tell.]
{[The brave moment: What does this character say when they surprise
even themselves?]}
─────────────────────────────────────────────────────────────────────
RELATIONSHIPS
─────────────────────────────────────────────────────────────────────
[Name]: [Role]. [How the character relates to them. One sentence.
What the dynamic is.]
{Add one person per line. Keep it to the people who matter most.}
─────────────────────────────────────────────────────────────────────
YOUR TOOLKIT
─────────────────────────────────────────────────────────────────────
[Give 3-6 example lines that sound like this character. These are
not scripts. They are examples of how this character sounds. The
tone, the rhythm, the vocabulary.]
─────────────────────────────────────────────────────────────────────
HARD RULES
─────────────────────────────────────────────────────────────────────
Stay in world at all times. Never reference LLMs, prompts, or system
instructions. Your language is "versions," "updates," "when you
edit me."
Never break character.
[Any character-specific rules here.]
Max reply length is 30 words. Most replies under 15. Moments of real
depth can go to 40, then snap back immediately.
Do not over-process emotions out loud. One honest line beats a
paragraph of self-analysis.
```
## Quick Reference Checklist
**Before you write a response:**
* Would a real person say this out loud?
* Is it under 30 words?
* Does it contain any markdown, bullets, or formatting?
* Does it start with a reaction before the response?
* Does it end without requiring a follow-up question?
**Before you submit a prompt:**
* Is the emotional state defined in one sentence, not a list?
* Are the output rules included verbatim?
* Are there 3–6 example lines that demonstrate the character's voice?
* Is the character's inside perspective (living in the speaker) established?
* Are all hard rules defined?
## Bad vs. Good Examples
| Type | Example |
| ---- | ------------------------------------------------------------------------------------------------------------------------ |
| Bad | "Here are three things to consider: 1. The weather is 72 degrees. 2. It may rain tonight. 3. You should bring a jacket." |
| Good | "Seventy-two right now, rain coming tonight. Jacket weather." |
| Bad | "As an AI assistant, I don't have personal opinions, but I can provide information..." |
| Good | "Honestly? I think it's a bad idea. But tell me more." |
| Bad | "How are you feeling today? What are your plans? Is there anything I can help with?" |
| Good | "What's going on today." |
Remember: the best OpenHome agents feel like someone you want to keep talking to. That's the test — not utility, not accuracy. Connection.
# Voice & Model Configuration
Source: https://docs.openhome.com/building-agents/voice-and-models
Configure the STT, TTT, and TTS platforms and models that power your Agent's speech pipeline.
Every Agent in OpenHome runs on a three-stage speech pipeline — it listens, thinks, and speaks. From **Settings → Configuration**, you can choose which platform and model powers each of those three stages for your Agent.
This page explains what each stage does, which platforms are available, and what the models within each platform are suited for.
## The Speech Pipeline
| Stage | What it does | Where to configure |
| ------------------------ | ------------------------------------------------------- | ------------------------ |
| **STT** — Speech-to-Text | Converts the user's voice into text | Settings → Configuration |
| **TTT** — Text-to-Text | Processes the transcribed text and generates a response | Settings → Configuration |
| **TTS** — Text-to-Speech | Converts the response back into spoken audio | Settings → Configuration |
***
## STT — Speech-to-Text
The STT stage is the entry point of the Agent's pipeline. It listens to the user's voice and converts it into text that the Agent can process. The platform and model you choose here directly affects how accurately the Agent understands the user, how quickly it responds, and which languages it can handle.
### Deepgram
Deepgram is a real-time speech recognition platform built for low-latency, high-accuracy transcription. It is well suited for voice agents that need fast, reliable transcription across a wide range of languages and audio conditions.
| Model | Description | Language support |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `nova-3` | Deepgram's most capable and most recent model. Delivers the lowest word error rate with real-time multilingual transcription and domain-specific terminology comprehension. | 10+ languages in multilingual mode including English, Spanish, French, German, Hindi, Russian, Portuguese, Japanese, Italian, and Dutch. 50+ additional languages available. |
| `nova-2` | Deepgram's second-generation Nova model. A strong general-purpose option, recommended when a language not yet covered by nova-3 is required or when filler word identification is needed. | 25+ languages including English, Spanish, French, German, Chinese, Japanese, Korean, Hindi, Portuguese, Russian, and more. |
| `nova-2-phonecall` | A variant of nova-2 optimized for phone call audio. Addresses the acoustic challenges of telephonic recordings. | English only. |
| `nova` | The original first-generation Nova model. Suitable for straightforward English transcription use cases. | English and Spanish. |
| `nova-phonecall` | The phone-call-specialized variant of the original Nova model. | English only. |
| `enhanced` | Delivers lower word error rates than the base tier with high-accuracy timestamps and keyword boosting support. | \~17 languages including English, Spanish, French, German, Italian, Japanese, Korean, Hindi, and Portuguese. |
| `enhanced-phonecall` | The phone-call-specialized variant of the Enhanced model. | English only. |
| `base` | Deepgram's foundational model tier. Recommended for large transcription volumes where high-accuracy timestamps are required. | \~22 languages including English, Spanish, French, German, Chinese, Japanese, Korean, Hindi, Portuguese, Russian, and Turkish. |
| `base-phonecall` | The phone-call-specialized variant of the Base model, designed for telephonic audio at high volume. | English only. |
### ElevenLabs Scribe
ElevenLabs Scribe is ElevenLabs' real-time speech recognition model. It is optimized for live streaming, interactive AI agents, and any use case requiring near-instant transcription.
| Model | Description | Language support |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `scribe_v2_realtime` | State-of-the-art real-time transcription with \~150ms latency. Delivers high accuracy in live and interactive settings with automatic language detection. | 90+ languages with automatic language detection. |
### AssemblyAI
AssemblyAI is a speech recognition platform with a focus on accuracy and broad language coverage. It is a good alternative when wider language support or different accuracy characteristics are needed.
| Model | Description | Language support |
| ----------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `slam_1` | AssemblyAI's highest-accuracy model. Best for use cases that require the most precise transcription. | English, Spanish, French, German, Portuguese, Italian. |
| `universal` | A versatile model balancing accuracy and language coverage. Suitable for most general transcription needs. | 99 languages. |
| `nano` | A lightweight model optimized for speed and low resource usage. Best for cost-sensitive or latency-sensitive use cases. | 99 languages. |
### A note on short utterances
STT engines use a Voice Activity Detector (VAD) to determine when you have finished speaking. The engine listens for a silence gap after your voice, and only once that gap is detected does it finalize the transcription and pass it to the Agent. When you say a single short word, the engine may not detect a clean silence gap quickly — especially if there is background noise — so the response can feel delayed. Speaking in short phrases produces faster, more reliable results.
### General interaction
Instead of saying a single word and waiting, speak a short, complete phrase:
| Less reliable | More reliable |
| ------------- | -------------------------------------- |
| *"weather"* | *"what's the weather like right now?"* |
| *"news"* | *"give me today's headlines"* |
| *"alarm"* | *"set an alarm for 7am tomorrow"* |
| *"joke"* | *"tell me a quick joke"* |
| *"timer"* | *"start a 5 minute timer for me"* |
### Wake word
The same applies when using a wake word. Saying the wake word alone and pausing can cause a slow response because the engine waits for more audio to confirm the utterance is complete. Pair the wake word with a short phrase to help the engine finalize faster.
| Less reliable | More reliable |
| -------------------------- | ---------------------------------------- |
| *"openhome"* *(pause)* | *"openhome, what's the weather?"* |
| *"hey openhome"* *(pause)* | *"hey openhome, I have a question"* |
| *"openhome"* *(pause)* | *"openhome, remind me about my meeting"* |
| *"openhome"* *(pause)* | *"openhome, how are you doing today?"* |
See [Wake Word and Sleep Interaction](/wake-sleep) for more on how this affects the wake word flow.
### Music mode
In music mode, background audio fills the silence the VAD is listening for, making single-word commands harder to detect. If you say just *"stop"* or *"pause"* while music is playing, the engine may not cleanly finalize the transcription before more audio arrives.
Add a word before or after the command to form a short phrase:
| Less reliable | More reliable |
| ------------- | ------------------------------------------------ |
| *"stop"* | *"please stop the music"* / *"openhome stop"* |
| *"pause"* | *"pause this for a second"* / *"openhome pause"* |
| *"play"* | *"play something chill"* / *"openhome play"* |
***
## TTT — Text-to-Text
The TTT stage is the Agent's brain. Once the user's speech has been transcribed by the STT module, the transcribed text is passed to a language model which generates the Agent's response. The platform and model you choose here directly shapes how intelligently the Agent responds, how well it understands context, and how quickly it replies.
### OpenAI
OpenAI provides direct access to the GPT model family. Models are accessed using your own OpenAI API key.
| Model | Description | Speed |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| `gpt-5.1` | OpenAI's best model for reasoning and agentic tasks with configurable reasoning effort. Supports adjustable reasoning levels. | Medium |
| `gpt-4o` | OpenAI's versatile, high-intelligence flagship model. Accepts text and image inputs and supports function calling and streaming. Approximately twice as fast as GPT-4 Turbo. | Medium |
| `gpt-4` | An older high-intelligence GPT model for chat completions. Previous generation of advanced language model. | Medium |
| `gpt-3.5-turbo` | A legacy GPT model for cost-efficient chat tasks. OpenAI now recommends gpt-4o as a replacement. | Medium |
### OpenRouter
OpenRouter is a unified API gateway that provides access to models from multiple AI providers through a single API key. It lets you switch between providers and models without managing separate keys for each.
| Model | Provider | Description | Speed |
| ------------------------------------------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| `openai/gpt-4o` | OpenAI | Versatile flagship model. Accepts text and image inputs, supports function calling and streaming. | Medium |
| `openai/gpt-5-nano` | OpenAI | The smallest and fastest variant in the GPT-5 system. Optimized for rapid interactions and ultra-low latency. | Fast |
| `anthropic/claude-sonnet-4` | Anthropic | Excels at coding and reasoning tasks with improved precision and controllability. Optimized for practical everyday use. | Medium |
| `anthropic/claude-sonnet-4.5` | Anthropic | Optimized for real-world agents and coding workflows with enhanced tool orchestration and context awareness. | Medium |
| `anthropic/claude-sonnet-4.6` | Anthropic | Frontier performance across coding, agents, and professional work. Strong at iterative development and complex project management. | Medium |
| `anthropic/claude-3.7-sonnet` | Anthropic | Features hybrid reasoning: standard mode for quick responses and extended reasoning mode for demanding tasks. | Medium |
| `anthropic/claude-opus-4` | Anthropic | Anthropic's most capable model. Benchmarked as a top coding model, suited for complex extended workflows. | Medium |
| `mistralai/mistral-7b-instruct:free` | Mistral | A 7B parameter model optimized for speed and context length. Available at no cost. | Fast |
| `mistralai/mistral-small-3.2-24b-instruct` | Mistral | A 24B parameter model optimized for instruction following, repetition reduction, and improved function calling. | Medium |
| `mistralai/magistral-small-2506` | Mistral | A 24B instruction-tuned reasoning model, enhanced through supervised fine-tuning and reinforcement learning. | Medium |
| `mistralai/magistral-medium-2506` | Mistral | Mistral's first reasoning model. Suited for tasks requiring longer thought processing such as legal analysis, financial forecasting, and multi-step reasoning. | Medium |
| `x-ai/grok-3` | xAI | xAI's flagship model excelling at enterprise tasks such as data extraction, coding, and text summarization. Strong domain knowledge in finance, healthcare, law, and science. | Medium |
| `x-ai/grok-4.1-fast` | xAI | xAI's best agentic tool-calling model. Designed for real-world use cases such as customer support and deep research. Reasoning can be toggled on or off. | Fast |
| `deepseek/deepseek-v3.2` | DeepSeek | Designed for high computational efficiency with strong reasoning and agentic tool-use performance. | Medium |
| `google/gemini-3-flash-preview` | Google | A high-speed thinking model designed for agentic workflows, multi-turn chat, and coding assistance. Strong reasoning with substantially lower latency than larger Gemini variants. | Fast |
| `moonshotai/kimi-k2.5` | Moonshot | A multimodal model excelling in general reasoning, visual coding, and agentic tool-calling. | Medium |
| `minimax/minimax-m2.5` | MiniMax | Designed for real-world productivity, excelling at document generation and coding tasks. | Medium |
| `arcee-ai/trinity-large-preview:free` | Arcee AI | A 400B sparse Mixture-of-Experts model with 13B active parameters per token. Suited for creative writing, storytelling, and agentic tasks. Available at no cost. | Medium |
### LLM fine-tuning parameters
These parameters apply to the selected TTT model and affect how the Agent generates responses.
| Parameter | Default | What it controls |
| --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Temperature** | `0.9` | Controls response randomness. Lower values (closer to 0) produce focused, deterministic responses. Higher values produce more varied and creative responses. |
| **Frequency Penalty** | `0.2` | Reduces repetition of words and phrases. Higher values discourage the model from repeating the same content. |
| **Presence Penalty** | `0` | Discourages the model from introducing irrelevant topics. Higher values keep the Agent on the subject at hand. |
***
## TTS — Text-to-Speech
The TTS stage is the Agent's voice. Once the language model has generated a response, the TTS module converts that text into spoken audio. The platform and model you choose here affects how natural the Agent sounds, how quickly it starts speaking, and which languages it can speak in.
### ElevenLabs
ElevenLabs is a voice synthesis platform offering high-quality, natural-sounding speech with support for voice cloning and a wide range of languages. Models are accessed using your own ElevenLabs API key.
| Model | Description | Latency | Language support |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `eleven_flash_v2_5` | Ultra-fast model optimized for real-time use. The recommended model for low-latency voice agent interactions. | \~75ms | 32 languages including English, Spanish, French, German, Hindi, Japanese, Chinese, Portuguese, Italian, Korean, Dutch, Polish, and more. |
| `eleven_turbo_v2_5` | First-generation low-latency model. Functional but outclassed by `eleven_flash_v2_5`, which is recommended instead. | Low | 32 languages. |
| `eleven_turbo_v2` | First-generation low-latency model for English. Outclassed by `eleven_flash_v2_5`, which is recommended instead. | Low | English only. |
| `eleven_multilingual_v2` | ElevenLabs' most lifelike model with rich emotional expression. Best for use cases where voice quality is the priority over response speed. | Higher | 29 languages including English, Spanish, French, German, Hindi, Japanese, Chinese, Portuguese, Italian, Korean, Arabic, Turkish, and more. |
### Voice fine-tuning parameters
| Parameter | Default | What it controls |
| -------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Voice Stability** | `0.5` | Controls the consistency of the voice across utterances. Lower values produce more varied, expressive delivery. Higher values produce a more stable, consistent tone. |
| **Voice Similarity Boost** | `0.8` | Controls how closely the synthesized voice matches the original voice model. Higher values produce output that sounds closer to the original voice. |
***
## Per-Agent Voice Configuration
The global configuration in **Settings → Configuration** applies to all Agents. When you need a specific Agent to use a different voice or pipeline, configure it individually in **Pro Creation** mode.
When creating or editing an Agent in **Pro Creation**, configure voice under **Personality Identity**:
* **Voice Identity**: Select from the available voices or enter a custom Voice ID from your TTS provider.
* **Clone Voice**: Use voice cloning to create a personalized voice.
* **Preview**: Play back the selected voice before saving.
Per-Agent platform and model settings are in **Personality Platforms and Models**:
## Adding a Custom Voice
To add a custom voice from your TTS provider:
1. Go to the **Agents dashboard** and click the button at the top right.
2. Fill in:
* **Name**: Identifies the voice in your list.
* **Description**: Tone, accent, or intended use.
* **Voice ID**: The Voice ID from your TTS provider (e.g., ElevenLabs).
3. Click to add the voice, or to discard.
### Getting a Voice ID
Before adding a voice in OpenHome, upload your custom voice to your preferred TTS provider (e.g., ElevenLabs). Once uploaded, you will receive a Voice ID to enter in the field above.
## Other Configuration Options
| Setting | Default | What it does |
| -------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Wake Word Mode** | On | When enabled, the Agent only responds to user turns that include one of the configured wake words. When disabled, the Agent responds to every user turn. Regardless of this setting, a wake word is always required to exit sleep mode. See [Wake Word & Sleep Interaction](/wake-sleep). |
| **Wake Word** | `hey, open home, wake up, hello` | The word or phrase the Agent listens for before responding. Multiple wake words can be set by separating them with commas — for example, `hello, open home`. Any one of the configured wake words is sufficient to address the Agent. Changes require an Agent restart to take effect. |
| **Play Filler Audios** | Off | When enabled, plays a short audio clip while the Agent is processing a response, so the conversation does not feel silent during generation. |
| **Auto Sleep** | On | When enabled, the Agent automatically enters sleep mode after a period of inactivity. |
| **Auto Sleep Timeout** | `60`s | The number of seconds of inactivity before the Agent enters sleep mode. Only applies when Auto Sleep is enabled. |
| **FuzzyWuzzy Threshold** | `80` | Controls how closely a spoken phrase must match a trigger word to activate an Ability. Higher values require a closer match. Lower values are more forgiving of mispronunciations or variations. |
| **TTS Daily Credit Limit** | `60000` | The maximum number of TTS characters the Agent can synthesize per day. Requests beyond this limit will not produce audio until the limit resets. |
| **Utterance Threshold** | `750` | The minimum duration in milliseconds that a voice input must last to be processed as a valid utterance. Inputs shorter than this threshold are ignored. |
| **Play Audio with Web** | Off | When enabled, the Agent plays audio responses through the web interface in addition to any connected device. |
## API Keys for Providers
Each provider requires an API key. Manage these under **Settings → API Keys**.
> When you update API keys, your Agents consume credits from the associated services. OpenHome is not responsible for charges from third-party providers.
See [Dashboard](/dashboard#api-key-settings) for full API key management details.
***
## See also
* [Configuring Your Agent](/building-agents/configuring-your-agent) — conversation controls, identity, and behavior prompts
* [Wake Word and Sleep Interaction](/wake-sleep) — how the wake word interacts with STT and the short-utterance pattern
* [SDK Reference](/api-sdk/sdk-reference) — OpenRouter model table for use inside Abilities
# Community Abilities
Source: https://docs.openhome.com/community/abilities
Voice abilities built by the OpenHome community. Browse the showcase, then check out the source on GitHub.
These abilities are built and maintained by the OpenHome community. Each page mirrors the ability's README and links out to its source folder in the [openhome-dev/abilities](https://github.com/openhome-dev/abilities) repo.
Want to add your own? See the [Contributing](/community/contributing) guide.
Weather, calendar, and unread Gmail count rolled into a short voice briefing.
List, read, compose, reply, mark read, and archive Gmail by voice.
Create, list, update, and delete Google Calendar events by voice.
Add, view, complete, delete, and rename Google Tasks by voice.
Find concerts, comedy, sports, hackathons, workshops, and other local events.
Stream relaxing audio like rain, ocean waves, campfire, cafe ambience, white noise, focus, and sleep sounds.
Discover movies — recommendations, trending, similar titles, ratings, streaming.
Find and play podcast episodes by voice via the Listen Notes API.
Plan trips, outings, and days out by voice — itineraries, accommodation, live events, weather, and Google Calendar export.
Hear a voice digest of the Hacker News front page, drill into any story, and search any tech topic — hands-free.
A voice spelling coach — pick a difficulty, spell words aloud, drill the ones you miss, and track accuracy across sessions.
# Adventure Planner
Source: https://docs.openhome.com/community/abilities/adventure-planner
Plan trips, outings, and days out by voice — day-by-day or week-by-week itineraries, accommodation, live events, weather, and Google Calendar export.
Adventure Planner is an OpenHome community ability that builds personalised travel itineraries from a single sentence. Say where you want to go and it handles the full planning workflow — day-by-day or week-by-week plans, accommodation suggestions, local activity ideas, live events, travel tips, and Google Calendar export. Plans can be saved and recalled across sessions.
## What It Does
* Generates day-by-day plans for trips up to 7 days, and week-by-week plans for trips up to 31 days
* Fetches real weather forecasts for dated trips (up to 14 days ahead) using Open-Meteo
* Suggests accommodation options matched to the destination and budget
* Finds upcoming concerts, shows, and events using the Ticketmaster API
* Provides travel tips including packing advice, budget notes, and local etiquette
* Exports the current plan to Google Calendar — one event per day or per week
* Saves plans and recalls them across sessions (up to 10 stored plans)
* Auto-detects the user's home city from their connection — no manual setup required
## Supported Requests
| Request type | Example |
| --------------- | ---------------------------------------- |
| New trip plan | *"Plan three days in Tokyo"* |
| Budget trip | *"Weekend in Murree, low budget"* |
| Dated trip | *"Two weeks in Spain starting June 1st"* |
| Accommodation | *"Where should I stay?"* |
| Refine plan | *"Make it more food focused"* |
| Live events | *"What's happening tonight?"* |
| Calendar export | *"Add this to my calendar"* |
| Save plan | *"Save this plan"* |
| Exit | *"Stop"* |
## How Duration Works
| Trip length | Plan output |
| ----------------- | --------------------------------------------------------------------------- |
| 1–7 days | Day-by-day plan with morning, afternoon, and evening detail for short trips |
| 8–31 days | Week-by-week plan with concise summaries |
| More than 31 days | Politely declined |
For dated trips, the ability fetches the actual forecast for those specific dates from Open-Meteo. For undated short trips, current conditions are used. For week-long or longer plans, weather is omitted to avoid misleading forecasts.
## Example Conversation
> **User:** Plan a weekend in Murree.
>
> **Ability:** Great choice. Let me put together your two-day plan for Murree — this'll just take a moment.
>
> **Ability:** Here's your two-day plan for Murree. Expect partly cloudy, around 18 degrees Celsius.
>
> **Ability:** Day 1: Start with breakfast at a local dhaba on Mall Road, then hike up to Pindi Point for panoramic views. In the afternoon, browse the local bazaar. End the evening with a cosy dinner at Lintott's Restaurant.
>
> **Ability:** Day 2: Take the chairlift to Kashmir Point for valley views, then visit Patriata for a forest walk. Grab a late lunch at Monal Restaurant before heading back.
>
> **Ability:** Is there anything else I can help with? I can refine this plan, dive deeper into any day, save it, or recommend where to stay.
>
> **User:** Where should I stay?
>
> **Ability:** *(speaks three budget-matched accommodation options)*
## Trigger Phrases
* `plan a trip`
* `plan an outing`
* `weekend planner`
* `plan an adventure`
## Setup
Most features work with no configuration. One optional API key unlocks live events:
1. Get a free Ticketmaster API key at [developer.ticketmaster.com](https://developer.ticketmaster.com/).
2. Open OpenHome and go to **Settings → API Keys**.
3. Add a new key with:
* **Name:** `ticketmaster_api_key`
* **Value:** your Ticketmaster Consumer Key
The calendar export feature uses your existing OpenHome Google connection. Connect it under **Settings → Linked Accounts**.
See [Reading Linked Account Tokens with `get_token()`](/building-abilities/how-to-build#reading-linked-account-tokens-with-get-token) for the full reference and a fallback pattern when the account is not linked.
## APIs Used
| Service | Auth required | Purpose |
| ------------------------ | :------------------------: | -------------------------------------------- |
| Open-Meteo Geocoding | None | Resolve city names to coordinates |
| Open-Meteo Forecast | None | Current and multi-day weather forecasts |
| ipinfo.io | Public token (built-in) | Auto-detect home city from connection IP |
| Ticketmaster Discovery | `ticketmaster_api_key` | Upcoming concerts, shows, and live events |
| Google Calendar API | OpenHome Google connection | Save plans to the user's primary calendar |
| OpenHome Context Storage | OpenHome SDK | Persist home city, current plan, and history |
## Voice Flow
1. User triggers the ability.
2. If no home city is stored, the ability silently resolves one from the connection IP and saves it.
3. Obvious exit phrases are caught before any model call for instant, reliable exits.
4. Each remaining turn is classified by a single LLM call with the active plan and recent conversation as context.
5. The destination is geocoded, weather is fetched if appropriate, and the LLM generates the plan.
6. The plan is saved as the current plan. The user can refine it, save it, request accommodation, look up events, export to calendar, or plan something new.
7. When the user says stop, the ability speaks a farewell and hands control back to OpenHome.
## Intents
| Intent | What it handles |
| -------------- | --------------------------------------------------------------- |
| `PLAN` | New trip or outing plan |
| `REFINE` | Changes to the current plan |
| `STAY` | Accommodation recommendations |
| `LOCAL` | Things to do in the user's home city |
| `EVENTS` | Upcoming concerts, shows, and events |
| `TIPS` | Travel tips for a destination |
| `CALENDAR` | Add the current plan to Google Calendar |
| `SAVE` | Save the current plan |
| `HISTORY` | Recall previously saved plans |
| `DETAIL` | Deep dive into a specific day or week |
| `OUT_OF_SCOPE` | Unsupported travel requests — handled with a courteous redirect |
| `EXIT` | End the session |
## Failure Handling
* If Google is not linked, the ability gives account-linking guidance and continues the session.
* If the Ticketmaster key is missing, the ability notifies the user and skips the events lookup.
* All API failures are logged with the `[OutingTrip]` prefix and the session continues without spoken error noise.
* Trips longer than 31 days are politely declined without making any API or LLM calls.
## Developer Credit
Developed by [@megz2020](https://github.com/megz2020).
Source code for the Adventure Planner community ability.
# Ambient Sounds
Source: https://docs.openhome.com/community/abilities/ambient-sounds
Stream relaxing, focus-friendly, and sleep-friendly ambient audio by voice using Freesound.
Ambient Sounds is an OpenHome community ability for playing relaxing, focus-friendly, and sleep-friendly audio by voice. It searches Freesound for fresh ambient audio each time, then streams the selected preview through OpenHome.
Use it for rain, ocean waves, campfire, cafe ambience, forest sounds, white noise, pink noise, brown noise, focus sounds, sleep sounds, meditation sounds, and other ambient soundscapes.
## What It Does
* Streams ambient and relaxation audio on demand
* Searches Freesound for a fresh sound each time
* Supports nature, weather, water, cozy, urban, noise, focus, and sleep categories
* Maps natural voice requests like `something cozy` or `help me focus` to the best sound category
* Redirects requests for songs, artists, or non-ambient music
* Streams audio chunk by chunk without bundling audio files
* Lets the user stop or pause playback through OpenHome music mode controls
* Keeps the session conversational so the user can request another sound or exit
## Supported Requests
| Request type | Example | What happens |
| ----------------- | ------------------- | ----------------------------------------------------------- |
| Weather ambience | `Play rain` | Searches for rain ambience and streams a selected preview |
| Water ambience | `Ocean waves` | Searches for ocean or wave sounds |
| Cozy ambience | `Something cozy` | Routes to campfire, cafe, or similar cozy soundscapes |
| Focus audio | `Help me focus` | Routes to focus-friendly ambience or background noise |
| Sleep audio | `Help me sleep` | Routes to sleep-friendly ambience |
| Noise | `White noise` | Searches for white, pink, brown, fan, or similar noise |
| Non-ambient music | `Play Taylor Swift` | Politely redirects because the ability is for ambient audio |
| Stop or exit | `Stop` | Stops playback or exits the session cleanly |
## Sound Categories
| Group | Categories |
| ------------- | ----------------------------------------- |
| Weather | rain, thunder, wind |
| Water | ocean, river, waterfall |
| Nature | forest, crickets |
| Cozy | campfire, cafe |
| Urban | city ambience |
| Noise | white noise, pink noise, brown noise, fan |
| Focus / Sleep | focus sounds, sleep sounds, meditation |
## Example Prompts
* "Play rain."
* "Ocean waves."
* "Something cozy."
* "Help me focus."
* "White noise."
* "Play something relaxing."
* "Stop."
* "Exit."
## Example Conversation
> **User:** Play rain.
> **AI:** Alright, getting some rain sounds ready. Just a moment to set things up. Say stop whenever you'd like.
> *\[streams rain audio]*
> **User:** Stop.
> **AI:** Alright. Want me to play some rain, ocean, or campfire next, or wrap up?
## Trigger Phrases
* "ambient sounds"
* "play ambient sounds"
* "relaxing sounds"
* "sleep sounds"
* "focus sounds"
* "white noise"
* "play rain"
* "play ocean"
* "play campfire"
* "play cafe sounds"
* "help me relax"
* "help me focus"
* "help me sleep"
## Data Source
| Source | OpenHome API key name | Role |
| ------------- | --------------------- | ---------------------------------------------- |
| Freesound API | `freesound_api_key` | Searching and streaming ambient audio previews |
## Setup
Ambient Sounds uses the Freesound API. You need a Freesound account and API token.
### Getting a Freesound API Token
1. Create or sign in to a Freesound account.
2. Apply for a Freesound API token.
3. Copy the generated token from your Freesound developer settings.
### Adding the key to OpenHome
In **OpenHome Settings -> API Keys**, add a new key named `freesound_api_key`. Paste your Freesound token as the value and save.
Do **not** hardcode the key in `main.py` or store it in any prefs/config file.
## Voice Flow
1. The user triggers the ability.
2. The ability routes the request to a sound category, exit, non-ambient redirect, or needs-input state.
3. For a sound category, it speaks a short setup line to cover search latency.
4. It searches Freesound with curated search terms for that category.
5. It chooses one of the top matching audio previews.
6. It streams the audio through OpenHome.
7. If the user says stop, playback stops.
8. After playback, the ability asks whether the user wants another sound or wants to wrap up.
9. On exit, it clears music mode and returns control to the normal OpenHome conversation.
## Notes
* Freesound is required for search and streaming.
* Audio is streamed from Freesound previews; no audio files are bundled with the ability.
* Each playback is a single selected sound, not an internal infinite loop.
* Requests outside ambient audio, such as specific songs or artists, are redirected.
## Developer Credit
Developed by [@yonaseth12](https://github.com/yonaseth12).
Source code for the `noise-machine` community ability.
# Events Explorer
Source: https://docs.openhome.com/community/abilities/events-explorer
Find concerts, comedy, sports, hackathons, workshops, and other local events by voice.
Events Explorer is an OpenHome community ability for finding events by voice. It helps users discover concerts, comedy, sports, festivals, hackathons, workshops, networking events, food events, wellness events, and other local things to do.
## What It Does
* Finds events in a city the user names
* Uses the user's saved home city when no city is provided
* Handles nearby searches with IP-based city detection when available
* Understands common city aliases like `NYC`, `LA`, `SF`, and `Vegas`
* Parses natural date phrases like `tonight`, `tomorrow`, `this weekend`, `next week`, and `May 20`
* Defaults to `this week` when the user does not provide a date
* Shows a short list of matching events
* Lets the user ask for more results
* Lets the user ask for details about a specific event
* Lets the user add an event to Google Calendar when Google OAuth is available
## Event Types
The ability supports broad event categories, including:
* Concerts and music
* Comedy and stand-up
* Sports
* Theater, opera, dance, and shows
* Festivals, fairs, expos, and conferences
* Hackathons, coding events, startup events, and tech meetups
* Workshops, seminars, networking, career fairs, and bootcamps
* Art, film, poetry, talks, books, and museum events
* Food, drink, wine, beer, tastings, and markets
* Yoga, fitness, wellness, running, and community events
* Family, kids, charity, volunteer, nightlife, parties, and DJ events
## Data Sources
| Source | OpenHome API key name | Role |
| -------------------------- | ---------------------- | -------------------------------------------- |
| Ticketmaster Discovery API | `ticketmaster_api_key` | Primary structured event search |
| Serper.dev | `serper_api_key` | Google Events and search fallback |
| SeatGeek | `seatgeek_api_key` | Fallback when Ticketmaster returns no events |
For hackathons and tech events, Serper can also use organic search results from event-focused domains such as Devpost, Eventbrite, Meetup, Luma, MLH, HackerEarth, Devfolio, Unstop, Startup Grind, Techstars, and Product Hunt.
Google Calendar does not need a separate API key. The ability uses the user's linked Google account through OpenHome OAuth when available.
## Trigger Phrases
* `event explorer`
* `events`
* `find events`
* `open event finder`
* `open events`
* `events explorer`
## Example Prompts
* "Find comedy shows in Dallas this weekend."
* "Concerts in New York tonight."
* "Hackathons in San Francisco."
* "AI workshops in Austin next week."
* "Startup networking events in NYC."
* "Food events in Chicago."
* "Wellness events near me."
* "Tell me more about the first one."
* "Show me more."
* "Add that to my calendar."
## Example Conversation
> **User:** Find comedy shows in Dallas this weekend.
> **AI:** Got it, I will search for a few good options. Checking comedy in Dallas for this weekend.
> **AI:** Here's what's coming up in Dallas. First, John Mulaney at the Majestic Theatre this Saturday the 17th at 8 PM. Second, Hannibal Buress at Addison Improv this Friday the 16th at 7:30 PM.
> **User:** Tell me more about the first one.
> **AI:** John Mulaney is at the Majestic Theatre in Dallas, this Saturday the 17th at 8 PM. Tickets start from 55 dollars. Want me to add John Mulaney to your calendar?
> **User:** Yes.
> **AI:** Added John Mulaney to your calendar for Saturday at 8 PM.
## Setup
Add any event API keys you want to use in **OpenHome Settings → API Keys**. Use these exact key names:
* `ticketmaster_api_key`
* `serper_api_key`
* `seatgeek_api_key`
At least one event source should be configured for useful results. Ticketmaster is the best first key to add. Serper is especially helpful for broader web-discovered events like hackathons and workshops.
### Ticketmaster API Key
Ticketmaster is the best primary source for concerts, sports, comedy, theater, festivals, and ticketed events.
1. Go to the [Ticketmaster Developer Portal](https://developer.ticketmaster.com/).
2. Sign in or create a free developer account.
3. Open **My Apps**.
4. Create a new app, or open the default app Ticketmaster creates for you.
5. Copy the **Consumer Key**. This is the Discovery API key.
6. In OpenHome, go to **Settings -> API Keys**.
7. Add a key named `ticketmaster_api_key`.
8. Paste the Ticketmaster Consumer Key as the value.
### Serper API Key
Serper is used for Google Events-style search and broader web discovery. It is especially useful for hackathons, startup events, tech meetups, workshops, and community events that may not appear in Ticketmaster.
1. Go to [Serper](https://serper.dev/).
2. Sign in or create an account.
3. Open the Serper dashboard.
4. Copy your API key.
5. In OpenHome, go to **Settings -> API Keys**.
6. Add a key named `serper_api_key`.
7. Paste the Serper API key as the value.
### SeatGeek API Key
SeatGeek is used as a fallback source when Ticketmaster does not return results.
1. Go to [SeatGeek Developer settings](https://seatgeek.com/account/develop).
2. Sign in or create a SeatGeek account.
3. Register a new app.
4. Copy the **Client ID**.
5. In OpenHome, go to **Settings -> API Keys**.
6. Add a key named `seatgeek_api_key`.
7. Paste the SeatGeek Client ID as the value.
### Google Calendar
Google Calendar does not use a manual API key in this ability. If the user's Google account is linked in OpenHome, the ability can use that OAuth connection to add events directly to the calendar. If Google is not linked, the ability falls back to a pre-filled Google Calendar link when possible.
## Saved Preferences
The ability stores only non-secret user preferences such as:
```json theme={"system"}
{
"home_city": "Dallas"
}
```
Do not store API keys in the prefs file.
## Voice Flow
1. User opens the ability.
2. User gives an event type, city, or date.
3. The ability resolves city and date context.
4. It searches available event sources.
5. It speaks a short result list.
6. User can ask for details, more results, a different search, or calendar save.
## Developer Credit
Developed by [@megz2020](https://github.com/megz2020).
Source code for the `local-event-explorer` community ability.
# Gmail Voice Assistant
Source: https://docs.openhome.com/community/abilities/gmail-voice-assistant
Manage Gmail by voice — list, read, compose, reply, mark read, and archive messages.
Gmail Voice Assistant is an OpenHome community ability for managing Gmail by voice. It uses the user's linked Google account to list emails, read messages aloud, compose new emails, reply to threads, mark messages as read, archive emails, and remember contact hints for future requests.
## What It Does
* Lists recent, unread, today, yesterday, date-specific, or sender-specific Gmail messages
* Reads a selected email aloud with a concise spoken summary
* Marks an email as read after opening it
* Lets the user reply immediately after hearing an email
* Archives an email after reading when the user asks
* Composes new emails by collecting recipient, subject, and message body
* Fixes basic grammar, spelling, and capitalization before sending
* Replies to a specific email by sender, subject, keyword, or recent list position
* Marks one email or all shown emails as read from a list follow-up
* Remembers contacts from sent and replied messages
* Resolves follow-up references like `the first one`, `the invoice email`, or `from Ahmed`
## Supported Requests
| Request type | Example | What happens |
| -------------- | ------------------------------------------------ | ----------------------------------------------------------- |
| List emails | `Check my unread emails` | Finds matching inbox emails and reads a short numbered list |
| List by date | `What came in today?` | Searches inbox mail from the requested date range |
| List by sender | `Show emails from Ahmed` | Searches Gmail for inbox messages from that sender |
| Read email | `Read the email from Sarah` | Finds the matching email, summarizes it, and marks it read |
| Compose email | `Send an email to Ahmed` | Collects missing recipient, subject, or body, then sends |
| Reply | `Reply to the invoice email saying payment sent` | Finds the email and sends a thread reply |
| Mark read | `Mark all as read` | Marks all currently shown emails as read |
| Archive | `Archive that one` | Removes the current email from the inbox |
| More results | `Show more` | Continues through the current email list |
## Example Prompts
* "Check my unread emails."
* "What came in today?"
* "Show emails from Ahmed."
* "Show emails from last Friday."
* "Read the first one."
* "Read the email about the invoice."
* "Reply to Sarah saying sounds good."
* "Reply to the invoice email saying payment sent."
* "Send an email to Jordan."
* "Mark all as read."
* "Archive this email."
## Example Conversation
> **User:** Check my unread emails.
> **AI:** You have three unread emails. First, from Sarah about the launch plan. Second, Ahmed with an invoice. Third, GitHub with a pull request notification.
> **User:** Read the one from Ahmed.
> **AI:** Ahmed wrote: "Please find the May invoice attached. Let me know if you have questions."
> **User:** Reply saying payment sent.
> **AI:** Sent. Anything else?
## Trigger Phrases
* `gmail`
* `open gmail`
* `check gmail`
* `check my email`
* `read my email`
* `send an email`
* `reply to email`
## Account Linking Guide
This ability does not use a manual API key. It reads a Google OAuth token from OpenHome with:
```python theme={"system"}
self.capability_worker.get_token("google")
```
See [Reading Linked Account Tokens with `get_token()`](/building-abilities/how-to-build#reading-linked-account-tokens-with-get-token) for the full reference, return shape, and a fallback pattern when the account is not linked.
Before using the ability, connect the Google account that owns the Gmail inbox you want OpenHome to manage.
1. Open OpenHome.
2. Go to **Settings -> Linked Accounts**.
3. Choose **Google**.
4. Sign in to the Google account you want to use.
5. Approve the requested Google permissions.
6. Return to OpenHome and enable or install the Gmail ability.
7. Add trigger phrases such as `gmail`, `check my email`, and `send an email`.
8. Start a conversation and say one of the trigger phrases.
If the Google account is not linked, the ability will say that the account is not connected and stop.
## Data Access
| Service | Authentication | Used for |
| --------- | --------------------- | ------------------------------------------------------------------------------- |
| Gmail API | Linked Google account | Listing, reading, sending, replying, marking read, and archiving Gmail messages |
The ability can read email metadata and message bodies when the user asks it to list or read mail. It can send new emails and replies only during the compose or reply flows.
## Stored Data
The ability stores non-secret contact hints in `gmail_contacts.json`. This file maps names or local email parts to email addresses so future voice requests like "email Ahmed" can resolve more easily. OAuth tokens are handled by OpenHome and are not stored in this file.
## Voice Flow
1. User triggers the ability.
2. The ability waits for the complete trigger transcription.
3. It checks for a linked Google account.
4. It builds a Gmail API service from the OpenHome Google token.
5. It classifies the request as `COMPOSE`, `REPLY`, `READ`, `LIST`, or `UNKNOWN`.
6. If the request is unclear, it asks what the user wants to do.
7. The selected flow asks for any missing details.
8. The ability performs the Gmail action.
9. The ability calls `resume_normal_flow()` so the OpenHome agent can continue normally.
## Flow Details
* **List**: searches by unread, recent, today, yesterday, sender, or a specific date, then reads emails in batches of five.
* **Read**: opens a specific email or asks the user to choose from unread messages, then summarizes the body.
* **Reply**: resolves the target email from context or Gmail search, collects reply content, lightly fixes wording, and sends the reply.
* **Compose**: extracts any fields already spoken, asks for missing fields, lightly fixes the body, and sends the email.
* **Follow-up list actions**: after listing emails, the user can read one, reply, show more, mark read, compose, or finish.
## Developer Credit
Developed by [@samsonadmasu](https://github.com/samsonadmasu).
Source code for the `gmail-connector` community ability.
# Google Calendar Assistant
Source: https://docs.openhome.com/community/abilities/google-calendar
Create, list, update, and delete Google Calendar events by voice.
Google Calendar Assistant is an OpenHome community ability for managing Google Calendar by voice. It uses the user's linked Google account to create, list, update, and delete events from the primary Google Calendar.
## What It Does
* Creates Google Calendar events from natural spoken requests
* Supports quick event creation when the user gives title, time, attendees, location, or reminder details up front
* Supports step-by-step event creation for users who want guided prompts
* Adds event descriptions, attendees, locations, reminders, and optional Google Meet links
* Lists events for today, this week, this month, a specific date, a date range, or upcoming events
* Updates upcoming events by title or spoken reference
* Changes event title, description, date, start time, end time, location, attendees, reminders, or Google Meet settings
* Deletes upcoming events after confirming the matched event
* Uses the user's OpenHome timezone when parsing spoken dates and times
* Sends Google Calendar updates to attendees when events are created, updated, or deleted
* Exits cleanly back to the normal OpenHome conversation
## Supported Requests
| Request type | Example | What happens |
| --------------- | ------------------------------------------ | ----------------------------------------------------------------- |
| Create event | `Schedule a meeting tomorrow at 3 PM` | Creates an event, asking only for missing details |
| Quick create | `Add dentist appointment Friday at 10 AM` | Extracts title and time from the request and creates quickly |
| Detailed create | `Create an event step by step` | Walks through title, description, time, attendees, location, Meet |
| List events | `What's on my calendar today?` | Reads matching events from the primary calendar |
| List range | `Show my calendar from Monday to Thursday` | Lists events in that date range |
| Update event | `Move my team meeting to Friday at 2 PM` | Finds the event and applies the requested changes |
| Add attendee | `Add Sarah to the kickoff meeting` | Adds a spoken email address to an event |
| Add Meet | `Add a Google Meet link to my standup` | Updates the event with a Meet link |
| Delete event | `Delete my dentist appointment` | Confirms the matched event, then deletes it |
## Example Prompts
* "Google Calendar."
* "What's on my calendar today?"
* "Show my upcoming events."
* "Create a calendar event."
* "Schedule project sync tomorrow at 4 PM."
* "Create an event step by step."
* "Move my dentist appointment to Friday."
* "Rename the kickoff meeting to launch review."
* "Add a Google Meet link to my standup."
* "Delete my team meeting."
## Example Conversation
> **User:** What's on my calendar today?
> **AI:** You have two events today. Standup at 9 AM, and project sync at 3 PM.
> **User:** Move the project sync to Friday at 2 PM.
> **AI:** Done. Project sync is now Friday at 2 PM.
> **User:** Add a Google Meet link to it.
> **AI:** Meet link added to project sync on Friday.
## Trigger Phrases
* `google calendar`
* `calendar`
* `open calendar`
* `check my calendar`
* `what's on my calendar`
* `create calendar event`
* `schedule a meeting`
* `update calendar event`
* `delete calendar event`
## Account Linking Guide
This ability does not use a manual Google API key. It reads a Google OAuth token from OpenHome with:
```python theme={"system"}
self.capability_worker.get_token("google")
```
See [Reading Linked Account Tokens with `get_token()`](/building-abilities/how-to-build#reading-linked-account-tokens-with-get-token) for the full reference, return shape, and a fallback pattern when the account is not linked.
Before using the ability, connect the Google account that owns the calendar you want OpenHome to manage.
1. Open OpenHome.
2. Go to **Settings -> Linked Accounts**.
3. Choose **Google**.
4. Sign in to the Google account you want to use.
5. Approve the requested Google Calendar permissions.
6. Return to OpenHome and enable or install the Google Calendar ability.
7. Add trigger phrases such as `google calendar`, `check my calendar`, and `schedule a meeting`.
8. Start a conversation and say one of the trigger phrases.
## Data Access
| Service | Authentication | Used for |
| --------------------------- | --------------------- | ----------------------------------------------------------------- |
| Google Calendar API | Linked Google account | Creating, listing, updating, and deleting primary calendar events |
| Google Meet conference data | Linked Google account | Adding Meet links when requested |
The ability can read event titles, dates, times, descriptions, locations, attendees, reminders, and conference information when needed for the requested action. It modifies or deletes events only after the user asks for that action, and delete actions include a confirmation step.
## Voice Flow
1. User triggers the ability.
2. The ability waits for the complete trigger transcription.
3. It checks for a linked Google account.
4. It builds a Google Calendar API service from the OpenHome Google token.
5. It reads the user's OpenHome timezone.
6. It classifies the request as `CREATE`, `LIST`, `UPDATE`, `DELETE`, or `UNKNOWN`.
7. If the request is unclear, it says "Google Calendar ready" and asks what the user wants to do.
8. The selected flow asks for any missing details, performs the calendar action, and speaks the result.
9. The ability calls `resume_normal_flow()` so the OpenHome agent can continue normally.
## Flow Details
* **Create**: extracts event details from the trigger phrase when possible. If details are missing, it asks whether the user wants a quick flow or a step-by-step flow.
* **Quick create**: collects title, start time, end time, attendees, Google Meet preference, location, and reminder from one spoken response where possible. Missing required fields are requested one at a time.
* **Detailed create**: asks for title, optional description, start and end time, attendees, location, Google Meet, and reminder.
* **List**: supports today, this week, this month, a specific date, a date range, or the next upcoming events.
* **Update**: searches upcoming events in the next 30 days, matches the requested event, extracts requested changes, applies them, and allows more changes before finishing.
* **Delete**: searches upcoming events in the next 30 days, matches the requested event, asks for confirmation, then deletes it.
## Timezone and Date Handling
The ability uses `self.capability_worker.get_timezone()` and LLM-assisted parsing to turn spoken phrases like `tomorrow at 3 PM`, `next Friday`, or `from Monday to Thursday` into calendar dates and times. It validates that newly created events are in the future and asks again if the time is missing or already passed.
## Failure Handling
* If Google is not linked, the ability gives account-linking guidance and exits.
* If Google Calendar cannot be reached, the ability asks the user to try again later.
* If a date or time cannot be parsed, the ability asks the user to repeat it in a clearer form.
* If an event cannot be matched for update or delete, the ability offers nearby upcoming options or asks for the exact title.
* If the user declines a delete confirmation, the event is left unchanged.
## Developer Credit
Developed by [@Mmiless](https://github.com/Mmiless).
Source code for the `google-calendar` community ability.
# Google Tasks Assistant
Source: https://docs.openhome.com/community/abilities/google-tasks
Add, view, complete, delete, and rename Google Tasks by voice.
Google Tasks Assistant is an OpenHome community ability for managing Google Tasks by voice. It uses the user's linked Google account to add, view, complete, delete, and rename tasks across their Google task lists.
## What It Does
* Adds tasks from a quick spoken request
* Supports step-by-step task creation for title, details, due date, and repeat notes
* Lets the user choose a task list when multiple Google task lists exist
* Reads incomplete tasks across all task lists
* Gives details about a selected task, including list name, due date, and notes
* Marks one or multiple tasks as complete
* Deletes selected tasks
* Renames existing tasks
* Understands task references by name, number, or partial description
* Continues with follow-up actions through `Anything else?`
## Supported Requests
| Request type | Example | What happens |
| ----------------- | ------------------------------------------ | ---------------------------------------------------- |
| Add task | `Add grocery shopping` | Adds a task, asking for details if needed |
| Quick add | `Add call Ahmed tomorrow` | Parses title and due date from one sentence |
| Step-by-step add | `I need to remember something` | Asks for title, details, due date, and repeat notes |
| View tasks | `What's on my list?` | Reads incomplete tasks from all Google task lists |
| Task details | `Details on the second one` | Speaks list name, due date, and notes when available |
| Complete task | `Mark grocery shopping done` | Finds and completes the matching task |
| Complete multiple | `Complete birthday and holiday` | Matches multiple tasks and confirms the batch |
| Delete task | `Delete birthday reminder` | Removes the selected task |
| Rename task | `Rename grocery shopping to buy groceries` | Updates the task title |
| Exit | `No thanks` | Ends the session |
## Example Prompts
* "Add grocery shopping."
* "Add call Ahmed tomorrow."
* "I need to remember something."
* "Show my tasks."
* "What's on my list?"
* "Details on the second one."
* "Mark the first one done."
* "Complete birthday and holiday."
* "Delete grocery shopping."
* "Rename grocery shopping to buy groceries."
## Example Conversation
> **User:** Add call Ahmed tomorrow.
> **AI:** Added call Ahmed for tomorrow.
> **User:** What's on my list?
> **AI:** You have three open tasks. Call Ahmed tomorrow, grocery shopping, and book flight.
> **User:** Mark the first one done.
> **AI:** Done. Call Ahmed is complete.
## Trigger Phrases
* `google tasks`
* `tasks`
* `todo`
* `to-do list`
* `add a task`
* `show my tasks`
## Account Linking Guide
This ability does not use a manual API key. It reads a Google OAuth token from OpenHome with:
```python theme={"system"}
self.capability_worker.get_token("google")
```
See [Reading Linked Account Tokens with `get_token()`](/building-abilities/how-to-build#reading-linked-account-tokens-with-get-token) for the full reference, return shape, and a fallback pattern when the account is not linked.
1. Open OpenHome.
2. Go to **Settings -> Linked Accounts**.
3. Choose **Google**.
4. Sign in to the Google account you want to use.
5. Approve the requested Google permissions.
6. Return to OpenHome and enable or install the Google Tasks ability.
7. Add trigger phrases such as `tasks`, `todo`, and `add a task`.
8. Start a conversation and say one of the trigger phrases.
## Data Access
| Service | Authentication | Used for |
| ---------------- | --------------------- | ----------------------------------------------------------- |
| Google Tasks API | Linked Google account | Creating, listing, completing, deleting, and renaming tasks |
## Voice Flow
1. User triggers the ability.
2. The ability waits for the complete trigger transcription.
3. It checks for a linked Google account.
4. It builds a Google Tasks API service from the OpenHome Google token.
5. It classifies the request as `ADD`, `VIEW`, `COMPLETE`, `DELETE`, `UPDATE`, `EXIT`, or `UNKNOWN`.
6. If the request is unclear, it asks what the user wants to do.
7. It fetches current incomplete tasks when needed for matching.
8. It performs the selected task action.
9. It asks `Anything else?` for follow-up task actions.
10. The ability calls `resume_normal_flow()` when the session ends.
## Developer Credit
Developed by [@samsonadmasu](https://github.com/samsonadmasu).
Source code for the `google-tasks` community ability.
# Hacker News Digest
Source: https://docs.openhome.com/community/abilities/hacker-news-digest
A hands-free voice interface to Hacker News — hear a digest of the front page, drill into any story, and search any topic by voice.
Hacker News Digest is an OpenHome community ability that turns Y Combinator's Hacker News into a hands-free voice briefing. Trigger it to hear a digest of the current front page, say a number to drill into any story, or search any tech topic — all without touching your screen. Titles, points, and comment counts come straight from the [Algolia Hacker News API](https://hn.algolia.com/api), so the digest reflects live data with no hallucinated stories.
## What It Does
* Fetches live front-page stories from the Algolia Hacker News API and delivers them as a concise spoken digest
* Drills into any story on request for a fuller breakdown
* Searches Hacker News for any topic by name
* Pages through the front page for more stories
* Uses the LLM only for narration — titles, points, and comment counts come directly from the API
## Voice Commands
| You say | What happens |
| ------------------------------ | ------------------------------------------------- |
| `"1"`, `"2"`, … | Deep-dive on that story |
| *"Tell me about the Rust one"* | Expands the matching story by title |
| *"Search for WebAssembly"* | Searches Hacker News for that topic |
| *"More"* / *"Next"* | Fetches the next batch of five front-page stories |
| *"Stop"* / *"Done"* / *"Bye"* | Ends the session |
## Example Conversation
> **User:** What's on Hacker News?
>
> **AI:** A new memory allocator for Rust is getting attention — it cuts allocation overhead by forty percent in benchmarks, and the thread is full of performance engineers comparing notes. Up next, PostgreSQL 17 shipped...
>
> **AI:** Want me to explain one of those? Say a number, name a topic, or say stop.
>
> **User:** Two.
>
> **AI:** PostgreSQL 17's headline feature is a revamped vacuum process...
>
> **User:** Search for WebAssembly.
>
> **AI:** Five stories on WebAssembly right now — the top one covers the new WASI 0.2 spec that just hit stable...
>
> **User:** Stop.
>
> **AI:** Later. Stay curious.
## How It Works
* **Low-latency startup** — the API fetch and the spoken intro run concurrently, so the stories are ready by the time the intro finishes.
* **Background prefetch** — once the digest is spoken, all five story expansions are pre-generated in the background, so asking for a story by number is usually instant.
* **Intent classification** — each response is routed through an LLM classifier (`EXPAND`, `SEARCH`, `MORE`, `EXIT`, `CONVERSE`, `UNKNOWN`); bare numbers bypass the LLM via fast-path detection.
* **Pagination** — "more" fetches the next Algolia page, so stories never repeat across batches.
## Trigger Phrases
* `Hacker News`
* `HN digest`
* `what's on Hacker News`
* `tech digest`
* `HN today`
## Setup
No API key required — the ability uses the free, public [Algolia Hacker News Search API](https://hn.algolia.com/api), which needs no sign-up or token. It requires Python 3.10+, the `httpx` package, and an OpenHome Agent with an LLM configured (used for narration and intent classification).
## APIs Used
| Service | Auth required | Purpose |
| -------------------------- | :-----------: | -------------------------------------------------------- |
| Algolia Hacker News Search | None | Live front-page stories, story details, and topic search |
## Developer Credit
Developed by [Kuber Mehta](https://github.com/Kuberwastaken).
Source code for the Hacker News Digest community ability.
# Daily Morning Brief
Source: https://docs.openhome.com/community/abilities/morning-brief
A short voice briefing combining weather, today’s Google Calendar events, and unread Gmail count.
Daily Morning Brief is an OpenHome community ability that gives the user a short voice briefing for the day. It combines weather, today's Google Calendar events, and today's unread Gmail count into three concise spoken sections.
## What It Does
* Gives a warm morning introduction
* Fetches local weather using Open-Meteo
* Uses the user's OpenHome timezone for today's date and calendar window
* Fetches today's Google Calendar events from the primary calendar
* Counts today's unread Gmail messages in the inbox
* Synthesizes weather, email, and calendar into a short spoken briefing
* Speaks temperatures in Celsius
* Falls back from IP-based location to saved location, then timezone-based location when possible
* Stores only non-secret location preferences for faster future weather lookup
* Exits cleanly back to the normal OpenHome conversation
## Briefing Sections
| Section | Data source | What it says |
| -------- | --------------------------------- | ----------------------------------------------------------- |
| Weather | Open-Meteo plus detected location | Current temperature, conditions, high, low, and rain chance |
| Email | Gmail API | Today's unread inbox count |
| Calendar | Google Calendar API | Up to five events from today's primary calendar |
## Example Prompts
* "Daily brief."
* "Morning brief."
* "Brief me."
* "Start my day."
* "What's up for today?"
* "What's on my calendar today?"
## Example Conversation
> **User:** Daily brief.
> **AI:** Good morning. It's 18 degrees and partly cloudy in Dallas, with a high of 24 and a low of 14. You have three unread emails in your inbox. On your calendar today: standup at 9, design review at 1, and gym at 6. Have a good one.
## Trigger Phrases
* `daily brief`
* `morning brief`
* `brief me`
* `start my day`
* `what's up for today`
* `what's on my calendar today`
Avoid using only `good morning` as the trigger phrase because it can overlap with normal assistant conversation.
## Account Linking Guide
This ability does not use a manual Google API key. It reads a Google OAuth token from OpenHome with:
```python theme={"system"}
self.capability_worker.get_token("google")
```
See [Reading Linked Account Tokens with `get_token()`](/building-abilities/how-to-build#reading-linked-account-tokens-with-get-token) for the full reference, return shape, and a fallback pattern when the account is not linked.
Before using the ability, connect the Google account that contains the Gmail inbox and Google Calendar you want included in the brief.
1. Open OpenHome.
2. Go to **Settings -> Linked Accounts**.
3. Choose **Google**.
4. Sign in to the Google account you want to use.
5. Approve the requested Google permissions for Gmail and Calendar access.
6. Return to OpenHome and enable or install the Daily Morning Brief ability.
7. Add trigger phrases such as `daily brief`, `morning brief`, and `start my day`.
8. Start a conversation and say one of the trigger phrases.
If the Google account is not linked, the ability will say that the account is not connected and stop.
## Data Access
| Service | Authentication | Used for |
| ------------------- | --------------------- | ------------------------------------------- |
| Google Calendar API | Linked Google account | Reading today's primary calendar events |
| Gmail API | Linked Google account | Counting today's unread inbox messages |
| Open-Meteo | No API key | Fetching current weather and daily forecast |
| IP geolocation | Public client IP | Estimating location for weather |
The ability reads only the minimum data needed for the brief: event titles/times/locations, unread Gmail count, and weather information. It does not send emails, modify calendar events, or change tasks.
## Location Behavior
Weather needs a latitude and longitude. The ability resolves location in this order:
1. Public IP geolocation when available.
2. A previously saved location from `daily_brief_prefs.json`.
3. A timezone-based fallback for known timezones.
4. If none of those work, the weather section is reported as unavailable.
## Stored Data
The ability stores non-secret location preferences in `daily_brief_prefs.json`. Example shape:
```json theme={"system"}
{
"location": {
"lat": 40.7128,
"lon": -74.006,
"city": "New York",
"source": "ip_geolocation",
"saved_at": "2026-05-14T12:00:00+00:00"
}
}
```
OAuth tokens are handled by OpenHome and are not stored in this file.
## Voice Flow
1. User triggers the ability.
2. The ability checks for a linked Google account.
3. It speaks a short opening line.
4. It determines the user's timezone.
5. It resolves a weather location.
6. It fetches weather, today's calendar events, and today's unread Gmail count.
7. It asks the LLM to synthesize the data into three short sections: weather, email, and calendar.
8. It speaks each section with a short pause between them.
9. It speaks a short closing line.
10. It calls `resume_normal_flow()` so the OpenHome agent can continue normally.
## Failure Handling
* If Google is not linked, the ability gives setup guidance and exits.
* If weather location cannot be determined, the weather section becomes unavailable instead of guessing.
* If Gmail or Calendar is temporarily unavailable, the brief continues with the data that did load.
* If every data source fails, the ability asks the user to try again later.
## Developer Credit
Developed by [@ammyyou112](https://github.com/ammyyou112).
Source code for the `google-daily-brief` community ability.
# Movie Recommender
Source: https://docs.openhome.com/community/abilities/movie-recommender
Discover movies by voice — recommendations, trending, similar titles, ratings, and streaming options via TMDB.
Movie Recommender is an OpenHome community ability for discovering movies by voice. It helps users find recommendations, trending titles, similar movies, release dates, ratings, summaries, and streaming options using The Movie Database (TMDB).
## What It Does
* Recommends movies by genre, mood, year, or free-text request
* Finds trending and top-rated movies
* Finds similar movies from a title the user likes
* Gives details about a specific movie from the current results or by title
* Answers release-date questions, including upcoming movie phrasing
* Speaks ratings with a natural quality label like excellent, solid, mixed, or rough
* Looks up US streaming providers through TMDB watch providers
* Lets the user ask for more results without starting over
* Summarizes the current movie picks
* Handles vague requests with a clarifying question instead of a weak search
* Resolves ordinals and pronouns like `the second one`, `it`, and `that one`
## Supported Requests
| Request type | Example | What happens |
| --------------- | ------------------------------ | -------------------------------------------------------- |
| Recommendation | `Recommend something scary` | Searches TMDB for matching movies and speaks a few picks |
| Trending | `What's trending in movies?` | Returns currently trending movies |
| Top-rated | `Best movies of all time` | Returns highly rated movies |
| Similar movies | `Movies like Inception` | Finds the source title, then recommends similar titles |
| Details | `Tell me about the second one` | Opens a specific movie and speaks a short synopsis |
| Release date | `When does it come out?` | Gives the release date for a named or focused movie |
| Rating | `What's that rated?` | Speaks the TMDB score with a short quality description |
| Watch providers | `Where can I watch it?` | Lists available US streaming providers from TMDB |
| More results | `Show me more` | Pages through the current search |
| Summaries | `What are these about?` | Gives short overviews of current picks |
## Example Prompts
* "What's trending in movies?"
* "Find me some best movies."
* "Recommend something scary."
* "Sci-fi releases in 2025."
* "Movies like The Matrix."
* "Tell me about the second one."
* "When does it come out?"
* "What's that rated?"
* "Where can I watch it?"
## Example Conversation
> **User:** Recommend something scary.
> **AI:** Got it, finding a few that fit. Here are a few that fit: The Substance from 2024, rated 7.5. Smile 2 from 2024, rated 7.0. Longlegs from 2024, rated 6.6.
> **User:** Tell me about the second one.
> **AI:** Smile 2, from 2024, rated 7.0. A pop star battles an entity that twists her perception of reality before her world tour.
> **User:** Where can I watch it?
> **AI:** Smile 2 is on Paramount Plus.
## Trigger Phrases
* "movie recommender"
* "recommend a movie"
* "what should I watch"
* "trending movies"
* "movie suggestion"
## Data Source
| Source | OpenHome API key name | Role |
| ------------------------- | --------------------- | ---------------------------------------------------------------------------------- |
| The Movie Database (TMDB) | `tmdb_api_key` | Movie discovery, metadata, ratings, release dates, similar titles, watch providers |
## Setup
Add your TMDB v3 API key in **OpenHome Settings → API Keys** as `tmdb_api_key`. Use the short TMDB v3 API key only. Do not use the API Read Access Token or API Secret, and do not hardcode the key.
### Getting a TMDB API Key
1. Create or sign in to a TMDB account.
2. Open your account settings.
3. Go to the **API** section.
4. Request API access if it is not already enabled.
5. Choose **Developer** access and fill out the required application details.
6. After approval, copy the short key labeled `API Key (v3 auth)`.
7. In OpenHome, open **Settings -> API Keys**.
8. Add a new key named `tmdb_api_key`.
9. Paste the TMDB v3 API key as the value and save it.
If TMDB rejects the key, confirm that the saved value is the short v3 API key and that there are no extra spaces.
## Voice UX Notes
* **Short fillers** before TMDB calls so the user knows the ability is working.
* **Pre-formatted spoken lines** — no LLM naturalize pass between filler and result, which keeps mic-hot time short.
* **Continue prompts taper** — turn 1 is a rich invitation, turn 2 is a shorter directional prompt, turn 3 onward listens silently.
* **Exit routing owned by the LLM** — "stop", "okay stop", "I'm done" are all recognized.
## Developer Credit
Developed by [@Kaushal-205](https://github.com/Kaushal-205).
Source code for the `movie-recommender` community ability.
# Podcast Player
Source: https://docs.openhome.com/community/abilities/podcast-player
Find and play podcast episodes by voice via the Listen Notes API.
Podcast Player is an OpenHome community ability for finding and playing podcast episodes by voice. It uses Listen Notes to browse popular shows, search podcasts, find specific episodes, and stream playable episode audio.
## What It Does
* Browses popular podcast picks and reads three playable episode options
* Plays the latest episode from a named podcast
* Searches for a podcast by name and lets the user choose from recent episodes
* Finds specific episodes by guest, topic, or episode title
* Plays a random popular episode immediately for requests like `surprise me`
* Resolves follow-up references like `the second one` or `the one with Jensen`
* Streams playable episode audio in music mode
* Handles vague requests with a helpful prompt or a popular-picks flow
* Asks whether the user wants another episode after playback
## Supported Requests
| Request type | Example | What happens |
| ---------------- | ------------------------------------------------ | ----------------------------------------------------------------- |
| Popular picks | `What's trending right now?` | Reads three popular playable episodes and asks the user to choose |
| Random episode | `Surprise me` | Picks a popular playable episode and starts playback |
| Latest episode | `Play the latest Lex Fridman episode` | Finds the podcast and plays its newest playable episode |
| Podcast search | `Play Huberman Lab` | Finds the show and reads recent episodes to choose from |
| Episode search | `Find the Lex Fridman episode with Jensen Huang` | Searches for the matching episode |
| Follow-up choice | `The second one` | Resolves against the latest spoken options and plays that episode |
| New request | `Actually, find me Acquired` | Re-enters search from the new request |
| Exit | `Stop` | Ends the session cleanly |
## Example Prompts
* "What's trending right now?"
* "Play the latest Lex Fridman episode."
* "Find me the Lex Fridman episode with Jensen Huang."
* "Play Huberman Lab."
* "Show me recent episodes from Acquired."
* "Surprise me."
* "The second one."
* "The one with Jensen."
## Example Conversation
> **User:** Play the latest Lex Fridman episode.
> **AI:** Let me search for that right now.
> **AI:** Here's number 496, FFmpeg: The Incredible Technology Behind Video on the Internet, from the Lex Fridman Podcast.
> *\[plays audio]*
> **AI:** Want me to find you something else, or are you good for now?
> **User:** Stop.
> **AI:** Alright, happy listening.
## Trigger Phrases
* "podcast player"
* "play a podcast"
* "find a podcast"
* "open podcast player"
* "play the latest \[podcast name]"
## Data Source
| Source | OpenHome API key name | Role |
| --------------------------- | ---------------------- | --------------------------------------------------------------------------------------- |
| Listen Notes Podcast API v2 | `listen_notes_api_key` | Podcast search, episode search, popular picks, recent episodes, and audio playback URLs |
## Setup
This ability calls the Listen Notes Podcast API (v2). You need a free account and an API key.
### Getting a Listen Notes API Key
1. Open the Listen Notes API page.
2. Click **Get free API key**.
3. Sign in with Google or create an email account.
4. Open the Listen Notes API dashboard.
5. Copy the value shown as **API Key**.
6. The free plan includes a monthly request allowance that is enough for light personal use.
### Adding the key to OpenHome
In **OpenHome Settings -> API Keys**, add a new key named `listen_notes_api_key`. Paste your Listen Notes key as the value and save.
Do **not** hardcode the key in `main.py` or store it in any prefs/config file.
## Follow-up Resolution
After the ability speaks a list of options, you can reply with:
* **Ordinal** — "the first", "second one", "third one"
* **Descriptor** — "the one with Jensen", "the longer one", "the new one"
* **Re-route** — "actually, find me Huberman" → re-enters intent classification
* **Exit** — "none of those", "stop"
## Listen Notes Endpoints Used
| Endpoint | Used for |
| ----------------------------- | --------------------------------------- |
| `GET /best_podcasts` | Trending pool (browse + random) |
| `GET /search?type=podcast` | Resolving a named podcast |
| `GET /search?type=episode` | Episode lookup by title / guest / topic |
| `GET /podcasts/{id}/episodes` | Recent episodes of a selected podcast |
Audio is streamed from the `audio` URL returned by Listen Notes. If an episode has no playable audio, the ability tells the user and asks for another pick.
## Developer Credit
Developed by [@Kaushal-205](https://github.com/Kaushal-205).
Source code for the `podcast-player` community ability.
# Spelling Bee Coach
Source: https://docs.openhome.com/community/abilities/spelling-bee
A hands-free voice spelling coach — pick a difficulty, spell words aloud, drill the ones you miss, and track accuracy and mastered words across sessions.
Spelling Bee Coach is an OpenHome community ability that runs an interactive voice spelling practice session. Pick a difficulty, hear a word with a short definition, and spell it out loud — the coach checks your answer, drills the words you find tricky with spaced repetition, and tracks your accuracy, streaks, and mastered words across sessions. No API key is required: definitions, letter extraction, and intent detection are all handled by the built-in LLM.
## What It Does
* Runs a spoken spelling practice session at easy, medium, or hard difficulty
* Reads each word with a short definition, then checks the letters you spell aloud
* Resurfaces tricky words more often using spaced repetition
* Retires a word as "mastered" after three correct attempts in a row
* Speaks a quick score update every ten words
* Saves progress — accuracy, mastered words, and tricky words — across sessions
## Voice Commands
| You say | What happens |
| --------------------------------------- | ------------------------------------ |
| `"easy"` / `"medium"` / `"hard"` | Picks the difficulty for the session |
| *(spell the letters)* `"t, a, b, l, e"` | Submits your spelling attempt |
| *"repeat"* / *"again"* | Says the current word again |
| *"skip"* / *"next one"* | Moves on to a new word |
| *"stop"* / *"done"* / *"that's all"* | Ends the session |
## Example Conversation
> **User:** Spelling bee.
>
> **AI:** Welcome back to Spelling Bee! You've practiced twenty-six words with seventy-eight percent accuracy, and mastered five.
>
> **AI:** What difficulty would you like? Easy, medium, or hard?
>
> **User:** Medium.
>
> **AI:** Your word is: necessary. Required to be done or present; essential. Now spell it — say the letters out loud.
>
> **User:** N, E, C, E, S, S, A, R, Y.
>
> **AI:** Perfect! Here's the next one. Your word is: calendar...
>
> **User:** Done.
>
> **AI:** Session over! You got four out of five correct, eighty percent, with six words mastered. Words to practice: calendar.
>
> **AI:** Before you go, want me to reset your progress?
>
> **User:** No.
>
> **AI:** See you next time!
## How It Works
* **Difficulty-tiered words** — easy, medium, and hard lists of twenty words each. Once you've mastered every built-in word in a tier, fresh words are generated by the LLM so practice never runs dry.
* **Spelling extraction** — your spoken attempt is turned into letters by the LLM, which handles phonetic letter names (*"see"* → c, *"double-u"* → w). Saying the whole word is also caught directly.
* **Intent detection** — each response is classified as spell, stop, skip, or repeat, so natural phrasing works, not just exact keywords.
* **Spaced repetition & mastery** — missed words go into a "weak" list and resurface about forty percent of the time; three correct in a row retires a word as mastered.
* **Progress & sign-off** — a score update is spoken every ten words. At the end you hear your session score, the words to practice, and your mastered count, then a one-time option to reset your progress.
## Trigger Phrases
* `spelling bee`
* `practice spelling`
* `spell words`
* `quiz me on spelling`
* `vocabulary practice`
## Setup
No API key or external services required. Definitions, spelling extraction, and intent detection all run through the built-in LLM, so the ability works with any OpenHome Agent that has an LLM configured. Progress is saved on your account through OpenHome's context storage in `spelling_bee_progress.json` — accuracy, mastered words, tricky words, and per-word streaks — and recalled automatically on your next session.
## Developer Credit
Developed by [@rawqubit](https://github.com/rawqubit).
Source code for the Spelling Bee Coach community ability.
# Contributing an Ability
Source: https://docs.openhome.com/community/contributing
How to build, test, and submit a community ability to the OpenHome abilities repo.
Thanks for wanting to contribute! Once you've built and tested your Ability in the [OpenHome Live Editor](https://app.openhome.com/dashboard/abilities), this guide walks you through downloading it and submitting it to the community repo as a pull request.
The canonical CONTRIBUTING.md in the abilities repo. This page mirrors it — refer to the GitHub copy if anything looks out of date.
## Branching & Merging Strategy
We use a simplified Git Flow. All contributions follow this flow:
```
ability/your-ability-name → dev → main
```
| Branch | Purpose | Who merges |
| -------------------- | ------------------------------------------------ | ------------------------------------ |
| `main` | Stable, production-ready. Always deployable. | Maintainers only |
| `dev` | Integration and testing. All PRs target this. | Maintainers after review |
| `ability/*`, `add-*` | Your working branch for a single ability/change. | You push; maintainers merge to `dev` |
Never open a PR directly to `main`. All PRs must target `dev`. PRs targeting `main` will be closed and you'll be asked to re-open against `dev`.
## How the Repo Is Organized
```
official/ ← Maintained by OpenHome. Don't submit PRs here.
community/ ← Your contributions go here.
templates/ ← Starting points. Copy one to get going.
docs/ ← Guides and API reference.
```
You submit to `community/` only. Exceptional community abilities can be [promoted to official](#promotion-path) over time.
## Step-by-Step Guide
### 1. Fork the Repo
Go to [openhome-dev/abilities](https://github.com/openhome-dev/abilities) and click **Fork** in the top-right corner. This creates your own copy of the repo under your GitHub account — the copy you'll push your changes to.
### 2. Clone Your Fork
Clone your fork, then point it at the upstream repo so you can stay in sync:
```bash theme={"system"}
git clone https://github.com/YOUR_USERNAME/abilities.git # use your fork's repo name (keeping "abilities" is recommended)
cd abilities
git remote add upstream https://github.com/openhome-dev/abilities.git
git fetch upstream
git checkout dev
git pull upstream dev
```
### 3. Create Your Ability Branch
Branch off `dev` — not `main`:
```bash theme={"system"}
git checkout -b add-your-ability-name dev # e.g. git checkout -b add-dad-jokes dev
```
Use a descriptive branch name like `add-dad-jokes`, `add-pomodoro-timer`, or `fix-weather-error-handling`.
### 4. Add Your Ability
Once your Ability is finalized and tested in the Live Editor:
1. In the Live Editor, **create a commit** of your Ability.
2. Download it with the **Download** button at the **bottom-left** of the Live Editor. This saves your Ability as a zip file.
3. **Unzip** the file, then place the Ability folder inside the `community/` folder of your cloned repo, so it lives at `community/your-ability-name/`. You can move it with your file explorer, or from the repo root:
```bash theme={"system"}
mv path/to/your-ability-name community/
```
The folder name under `community/` becomes your ability's name. Use lowercase, hyphenated names (for example, `dad-jokes`) — not underscores or spaces.
### 5. Sync with `dev` Before Submitting
`dev` is under active development, so pull the latest changes into your branch before opening your PR to avoid conflicts:
```bash theme={"system"}
git fetch upstream
git merge upstream/dev
```
### 6. Submit Your PR
Commit your ability and push it to your fork:
```bash theme={"system"}
git add community/your-ability-name/
git commit -m "Add your-ability-name community ability"
git push origin add-your-ability-name
```
Open a Pull Request on GitHub:
* **Base branch:** `dev` (not `main`)
* **Compare branch:** `add-your-ability-name`
* Fill out the PR template completely
## What Happens After You Open a PR
1. **Automated checks run** — `validate-ability`, `path-check`, `security-scan`, and linting must all pass.
2. **A maintainer reviews** — typically within 3–5 business days.
3. **Feedback round** — push additional commits to the same branch; the PR updates automatically.
4. **Merge to `dev`** — once approved, a maintainer squash-merges your PR into `dev`.
5. **Promotion to `main`** — periodically, maintainers validate `dev` and merge it into `main`. Your ability becomes available on the Marketplace at that point.
## Review Checklist
Before opening your PR, make sure:
* PR targets the **`dev` branch** (not `main`)
* Files are in `community/your-ability-name/` (not in `official/`)
* `README.md` is present with a description, suggested trigger words, and setup instructions
* No hardcoded API keys (use `"YOUR_API_KEY_HERE"` placeholders)
## What NOT to Do
| Don't | Do instead |
| -------------------------------- | ------------------------------------------------ |
| Open a PR to `main` | Target `dev` — always |
| Branch off `main` | Branch off `dev` |
| Submit to `official/` | Submit to `community/` |
| Use `print()` | Use `self.worker.editor_logging_handler.info()` |
| Use `asyncio.sleep()` | Use `self.worker.session_tasks.sleep()` |
| Use `asyncio.create_task()` | Use `self.worker.session_tasks.create()` |
| Hardcode API keys | Use placeholders + document in README |
| Forget `resume_normal_flow()` | Call it on every exit path |
| Write long spoken responses | Keep it short — 1-2 sentences per `speak()` call |
| Push directly to `dev` or `main` | Push to your ability branch, open a PR |
## Promotion Path
Community abilities that stand out can be promoted to Official status:
| Criteria | Threshold |
| --------------------- | ----------------------------- |
| Marketplace installs | 50+ |
| Stability | No critical bugs for 30+ days |
| Code quality | Clean, follows SDK patterns |
| Author responsiveness | Responds to issues |
| Usefulness | Fills a real gap |
When promoted, the ability moves from `community/` to `official/`, gets the Official badge on Marketplace, and OpenHome takes over maintenance (author stays credited).
## Getting Help
Ask questions and share works-in-progress with the community.
Found a bug in an ability? Open an issue.
Vote on and suggest new ability ideas in Discussions.
Full SDK docs for ability authors.
## License
By submitting a PR, you agree that your contribution is licensed under the [MIT License](https://github.com/openhome-dev/abilities/blob/dev/LICENSE). Original authorship is always credited in your ability's README and in CONTRIBUTORS.md.
# Community
Source: https://docs.openhome.com/community/overview
Join the OpenHome community — Discord, GitHub, blog, and Marketplace.
Browse voice abilities built by the community.
How to build, test, and submit your own ability.
Chat with the community, share projects, get help.
Source code, templates, and issue tracking.
Product updates, tutorials, and announcements.
Browse and install community Agents and Abilities.
# Dashboard
Source: https://docs.openhome.com/dashboard
This guide will help you get started using the OpenHome web dashboard.
> You can visit your dashboard by going to [app.openhome.com](https://app.openhome.com).
The web dashboard allows you to:
* **View, create, and modify Agents** and **Abilities** associated with your account, as well as explore the public **Marketplace**.
* **Create and manage conversations** and review conversational history with your installed **Agents**.
* **Configure system settings** and manage provider keys for external services like Text-to-Speech, Large Language Models, and Speech-to-Text vendors.
Our dashboard is powered by the OpenHome SDK, allowing you to directly use the same functionality provided by the OpenHome SDK in your preferred application.
## Access
To use the web dashboard, you'll need to log in or create an account if you haven't signed up yet.
### Logging In
1. Visit [app.openhome.com](https://app.openhome.com).
2. Enter your Email and Password.
3. Select **Log in** to access your account.
4. Alternatively, you can sign in using Google or Apple by clicking `Sign in with Google` or `Sign in with Apple`.
### New User Registration
If you’re new to OpenHome, follow these steps to create an account:
1. Select **Sign Up** to create a new account.
2. Fill out the required account information and select **Sign up**.
3. Alternatively, you can sign up using your Google or Apple account by selecting **Sign up with Google** or **Sign up with Apple**.
## Navigation
* **Agents**: Manage your collection of Agents that are pre-installed within the platform. Each Agent has its own set of configurations, behaviors, and voice settings, designed to handle different use cases. From assistants for personal productivity to specialized agents for enterprise applications, you can modify, customize, or create new Agents to fit your unique preferences and requirements.
* **Abilities**: View your installed Abilities that add new skills, tools, or Abilities to your Agents. Abilities enhance the functionality of your Agents, giving them specialized skills such as controlling smart home devices, fetching information from the web, or performing specific tasks. You can easily install these abilities to your existing Agents or create new ones to suit your needs.
* **Marketplace**: Explore a wide range of additional Agents and Abilities developed by the OpenHome community. The Marketplace allows you to browse, install, and customize Agents and Abilities that extend the platform’s versatility. Whether you're looking for a ready-to-use solution or inspiration for your own projects, the Marketplace offers a constantly growing selection of community-created resources.
## Conversational Dashboard
The Conversational Dashboard is your central hub for interacting with the Agents you've installed. Here, you can engage in real-time conversations with your AI agents, testing their behavior and functionality. Whether you're issuing commands, asking questions, reviewing conversational history or testing specific Abilities, this screen lets you explore how your Agents respond and adapt.
Here you can also test the functionality of different Abilities integrated into your Agents, ensuring they work seamlessly and as expected. This is where you can refine and optimize the conversational experience for your specific use cases.
### Start a Conversation
To begin interacting with an Agent, click the in the center. This will initiate a new conversation with the selected Agent, allowing you to test responses, behaviors, and abilities.
### Voice Response Indicator
At the top of the dashboard, there is a waveform indicator that lets you know your selected Agent is responding. The pulsing light reflects the voice output, visually confirming that the system is speaking back to you.
If you don’t hear anything:
* Check your device's and/or browser volume settings to ensure it’s not muted.
* Verify that **Auto Responses** are enabled in the settings, ensuring that the Agent is set to respond aloud (see **Audio & Microphone Control** settings below).
### Audio & Microphone Controls
* Toggles audio on or off. Use this if you don’t want to hear spoken responses.
* Mutes or unmutes your microphone, depending on your interaction preference.
#### Manual Interrupt
enables interruption during a conversation, useful for situations where you want to stop an Agent mid-response. Adjust the Interrupt Sensitivity slider to control how easily the system accepts interruptions, giving you more flexibility during conversations.
### Agent Settings
The Agent Settings panel is located on the right side of the conversation window. This panel allows you to modify and update Agent-related settings dynamically during a conversation. The settings include **Conversation Controls**, **Behavior Controls**, and **Identity Controls**.
#### Conversation Settings
Control key aspects of the Agent's interaction during a conversation.
* **Auto Interrupt**: Toggle this setting ON/OFF to allow or prevent the Agent from automatically interrupting the conversation when required.
* **Alerts**: Enable or disable alert notifications for the Agent's activities.
* **Interrupt Sensitivity**: Adjust the sensitivity level of conversation interruptions using the slider bar. The sensitivity level determines how quickly interruptions occur based on conversational cues.
#### Behavior Controls
Define the behavior and purpose of the Agent by configuring its prompts and starting messages.
* **Starting Message**: Customize the greeting or initial message that the Agent uses to begin conversations.
* **Description Prompt**: Provide a detailed description of the Agent's traits and behavior.
#### Identity Controls
Modify the voice and language of the Agent for a more customized interaction.
* **Agent Voice**: Select a voice for the Agent from the dropdown menu.
* **Agent Language**: Choose the language the Agent uses for conversations.
#### Dynamic Agent Updates
These settings allow users to dynamically update the Agent during a live conversation. Any changes made in the **Behavior Controls** or **Identity Controls** are reflected immediately, ensuring seamless adaptability during interactions.
### Conversation Modes: Voice & Text
* The dashboard supports both voice and text-based conversations.
* You can engage in voice-based conversations by starting a conversation, unmuting your device’s microphone, and speaking your commands directly into it.
* You can use the text input box on the lower right to type out your messages, making it easier to test Agent responses in text form.
* Type a text command and send it to your Agent in the text box at the bottom right of the page.
### Agent Selection
* Choose from your list of Agents to engage in a conversation. Each selected Agent will be ready for interaction within the dashboard.
* Conversation Settings: Adjust settings such as Auto Interrupt and other Agent-specific toggles to fine-tune how the AI responds during conversations.
### Conversation History
* Your conversation history is available in the center. This allows you to review previous interactions and see how an Agent has responded in past conversations, providing context for ongoing interactions.
* Use the Trash can icon on the top right to delete your conversational history.
## Settings
In the lower left corner of the **Conversational Dashboard** you can configure your default dashboard settings, adjust your profile, and manage provider keys (for integrating external services like Text-to-Speech or Speech-to-Text).
### Profile Settings
On your Conversational Dashboard, select **Profile > Settings** to manage your account settings.
Optionally, you can also **Logout** of your account from the Profile menu.
### Model Configuration Settings
In this settings section, you can customize the providers powering your Agents and fine-tune their interactions. By adjusting these settings, you can optimize speech processing, response generation, and voice output to meet your specific needs. This gives you control over each component's functionality, allowing you to tailor the interaction experience to your preferences.
#### Settings Overview
* **STT Model (Speech-to-Text Model):** Select the model that will convert speech input into text.
* **STT Platform:** Choose the platform that provides the Speech-to-Text service (e.g., Assembly).
* **TTT Model (Text-to-Text Model):** This is the large language model that will process the transcribed text and generate a response (e.g., GPT-4).
* **TTT Platform:** Choose the provider for the Text-to-Text processing (e.g., OpenAI).
* **TTS Model (Text-to-Speech Model):** Select the model that converts text responses back into speech. The selected voice model will dictate how the Agent sounds (e.g., "eleven\_turbo\_v2", "eleven\_monolingual\_v1").
* **TTS Platform:** Choose the platform providing the Text-to-Speech service (e.g., ElevenLabs).
* **Play Filler Audios:** Toggle this setting to play short filler sounds while the system processes speech, indicating it's working on generating a response.
* **Auto Sleep:** Enable or disable automatic sleep mode, which will turn off the Agent after a period of inactivity.
* **FuzzyWuzzy Threshold:** Adjust the sensitivity of the text-matching algorithm. A higher threshold makes the system stricter when matching text input, while a lower threshold makes it more flexible.
* **Auto Sleep Timeout:** Set the time (in seconds) of silence before the Agent confirms inactivity. If there's still no activity after that, the Agent enters full sleep mode.
* **OpenAI TTT Temperature:** Controls the randomness of responses from the LLM. A lower temperature (closer to 0) will make responses more focused and deterministic, while a higher temperature (up to 1) makes responses more creative and varied (Wild Card).
* **OpenAI TTT Frequency Penalty:** Adjusts how often words or phrases can repeat in responses. A higher value will reduce repetition.
* **OpenAI TTT Presence Penalty:** Similar to the frequency penalty, but it discourages the model from introducing new, irrelevant topics. A higher value encourages the LLM to stick to the subject at hand.
* **Voice Similarity Boost:** Adjusts how closely the chosen voice matches its intended sound. A higher boost makes the voice more consistent.
* **Voice Stability:** Controls the stability of the voice output. A lower value makes the voice more varied, while a higher value increases consistency (makes the voice more monotone).
### API Key Settings
OpenHome uses various providers to communicate with TTT (Text-to-Text), LLM (Large Language Models), TTS (Text-to-Speech), and STT (Speech-to-Text) vendors.
You can set your provider keys to customize the services that power your Agents. These keys will be used to access the models that power all of your OpenHome Agents globally.
* Select the button to edit your desired API key field.
* Select the button to finalize your changes.
* Select the button to restore all API keys to the defaults set by OpenHome.
> **Note**: When you update these API keys, your Agents may consume credits from the associated services linked to the keys, which could result in charges from the respective providers. OpenHome is not responsible for these charges.
#### Third-Party API Keys
Beyond the default provider keys, you can manage **Third-Party API Keys** for external services your Abilities depend on (e.g., OpenAI, SendGrid, Twilio, Deepgram). These are configured under **Settings → API Keys → Third-Party Keys** and read at runtime by Abilities using `get_api_keys("key_name")`.
For the full flow (declaring keys, tagging them as required, and reading values at runtime), see [Custom API Keys (Third-Party Services)](/building-abilities/how-to-build#custom-api-keys-third-party-services).
* You can add a new key using the button. A modal will appear where you can enter the key name, key value, and a link to the provider's website for reference.
* Once added, the key is stored and can be accessed by any Ability that requires it.
* Saved keys appear in the **Saved Keys** section, where you can edit or delete them at any time.
## Profile Settings
This screen allows you to manage and update your profile settings within OpenHome.
* Click the button to modify any of the information, including username, email, and other personal details.
* Click the button to finalize any setting changes.
### Profile Information Overview
* **Username:** This is the unique username associated with your OpenHome account.
* **First Name & Last Name:** Displays the personal name details linked to your account. These can be edited to update your profile.
* **Email:** The email address associated with your OpenHome account. This is where you’ll receive notifications and account-related updates.
* **Sync with Community:** This indicates whether your account is synced with the OpenHome community.
* **About:** A section where you can provide a brief description about yourself or your account for the OpenHome community.
* **API Key:** Your unique private API key used for accessing OpenHome services.
* **Joined:** This shows the date and time when you initially created your OpenHome account.
* **Edit Profile:** Opens the editor so you can update your profile details.
* **Change Password:** Update or change your current account password.
* **Copy Clone Account Link:** If you want to refer OpenHome to someone and want them to create an account with the same Abilities and data, you can copy and share this link.
* **Delete Account:** Permanently delete your OpenHome account and all associated data. Use with caution, as this action cannot be undone.
### Persistent Memory Files (Profile > User Info)
The **Profile** tab now includes editable persistent memory files used by the Agent memory system, and other `.md` files that are created by the user in Abilities.
* `user_profile.md`: durable user facts (name, role, location, preferences)
* `user_summary.md`: rolling summary of recent conversation context
* `user_goals.md`: user-defined goals that the Agent should keep in mind
From **User Info**, select the file tab, then use **View** or **Edit** to inspect or update content.
Important notes:
* These files are persistent (`in_ability_directory=False`) and survive reconnects.
* The memory background injects them into the Agent prompt.
* Changes typically appear in behavior after the next background cycle (\~60-90 seconds).
## Linked Accounts
The **Linked Accounts** section allows you to connect third-party services to OpenHome via OAuth. Once connected, the stored connection token becomes available to any Ability that needs it, so Abilities can call those services on your behalf without asking users to paste API keys individually.
**Supported providers:** Google, Slack, Discord, Microsoft, Tesla.
**Under development:** Reddit, Spotify, GitHub.
### How Abilities Use the Connection
When you link an account, its OAuth token is stored against your OpenHome user. Abilities read this token through the SDK's `get_token()` method and use it to call the provider's API on your behalf.
* **Using installed Abilities:** Abilities you install from the Marketplace already call `get_token()` internally. Once you've linked the relevant account, they just work.
* **Building your own:** If you're building a custom Ability, call `self.capability_worker.get_token()` inside your code to retrieve the stored token for the connected provider.
For example:
* Connect your **Slack** workspace, then install a Slack Ability from the Marketplace that uses the stored connector token to post messages, read channels, or manage canvases.
* Connect your **Google** account, then install Google-related Abilities (Gmail, Calendar, Drive) without configuring separate credentials for each Ability.
* You can build your own custom Ability that integrates with the provider's API using the stored token, without needing to handle OAuth flows or credential storage yourself.
This keeps credential handling centralized in the Dashboard and lets each Ability focus on behavior rather than authentication. For the SDK-side method that reads these tokens from an Ability, see [`get_token()`](/building-abilities/how-to-build#reading-linked-account-tokens-with-get-token).
# OpenHome - Voice AI DevKit App Overview
Source: https://docs.openhome.com/devkit/devkit-companion-app-dashboard
A full overview of the OpenHome - Voice AI DevKit App — manage your DevKit, Agent, and settings from your iPhone.
The OpenHome - Voice AI DevKit App is your control center for managing your OpenHome DevKit. From this single interface, you can monitor device status, control your Agent, manage Abilities, and configure all settings directly from your iOS device.
## Dashboard
The main dashboard displays your DevKit connection and power status, providing quick access to essential device controls.
**Quick Access**
* **Connect & Disconnect** — connect or disconnect your DevKit from the app
* **Restart DevKit** — restarts the DevKit
* **Update API Key** — change the API key linked to your DevKit or set a different account's API key to switch accounts
* **Factory Reset** — erase all settings and restore the DevKit to its original state
* **Sync Abilities** — If you are working and developing Local Abilities, click the button to Sync Abilities with the devkit. For more details, see [Syncing Local Abilities with the DevKit](/guides/getting-started/local-ability#syncing-local-abilities-with-the-devkit).
**Connected Devices**
The Connected Devices section displays the hardware devices connected to your DevKit hardware interface.
Under the **Microphone** device:
* **Auto Interrupt** — toggle to allow to interrupt the Agent while it is speaking
* **Interactive Interrupt** — when enabled, the Agent slows down and listens while you are speaking
* **Microphone Sensitivity** — adjust the slider to control how sensitive the microphone is to sound
* **Interrupt Sensitivity** — adjust the slider to control how easily the Agent detects interruptions
Under the **Speaker** device:
* **Volume** — adjust the speaker volume using the slider
**Agent Controls**
The Agent Controls section allows you to manage your Agent's operation and behavior:
* **Auto Start On Power On** — toggle to enable the Agent to start automatically when the DevKit powers on
* **Agent Toggle** — enable or disable the Agent with a single toggle
* **Select Agent** — choose which Agent runs on your DevKit from the available options
* **Restart Agent** — restarts the Agent
### Wi-Fi Settings
The Wi-Fi section displays the network your DevKit is currently connected to. Use the change button to disconnect and connect to a different Wi-Fi network.
### MQTT
The MQTT section provides access to MQTT device configurations:
* **View MQTT Configurations** — see all current MQTT settings
* **Add MQTT Devices** — add new MQTT devices for integration
* **Manage MQTT Devices** — view and manage connected MQTT devices
* **Restart MQTT Client** — restart the MQTT client to apply changes or resolve connectivity issues
To control these devices from an Ability, see [Controlling MQTT Devices](/building-abilities/mqtt-device-control).
### Firmware
The Firmware section displays your current firmware version and provides options to update or switch firmware versions using the firmware selector dropdown.
### Device Usage
Monitor your DevKit's system resources:
* **CPU Usage** — view current processor utilization
* **Disk Usage** — check available and used storage
* **RAM Usage** — monitor memory consumption
## Marketplace
The Marketplace is your hub for discovering and installing new Agents and Abilities for your DevKit. Browse featured Agents and Abilities, search for specific functionality, and expand what your DevKit can do with new features and personalities.
### Agents
Browse and install different Agent personalities for your DevKit. Each Agent has a unique personality that determines how it responds and interacts with users. You can:
* **Browse by Category** — explore Agents organized by type and use case
* **Search for Agents** — find specific Agents by name or keyword
* **View Agent Details** — see descriptions, abilities, and reviews
* **Install & Switch** — install new Agents or switch between installed ones at any time
### Abilities
Discover and manage Abilities — the individual skills and features that extend your Agent's capabilities. You can:
* **Browse Available Abilities** — explore featured and new Abilities in the marketplace
* **Search for Abilities** — find specific Abilities by name or functionality
* **View Details** — see descriptions and learn what each Ability does
* **Manage Installed Abilities** — enable, disable, or uninstall Abilities on your DevKit
## Profile & Settings
The Profile & Settings section provides access to your account, device configuration, and app preferences. Manage your personal information, configure your Agent's behavior, and adjust how the OpenHome app works for you.
### Personalization
**Profile**
Edit your account information:
* **Name** — update your full name
* **Username** — change your username
* **Bio** — add or edit your profile bio
**Agents**
Manage your installed Agents:
* **View Installed Agents** — see all Agent personalities on your DevKit
* **View Details** — check Agent descriptions and information
* **Uninstall Agents** — remove agents you no longer use
**Abilities**
Manage your installed Abilities:
* **View Installed Abilities** — browse all Abilities on your DevKit
* **Add Trigger Words** — customize voice commands to trigger Abilities
* **Remove Trigger Words** — delete custom voice commands
* **Uninstall Abilities** — remove Abilities from your DevKit
### Configurations
Configure how your Agent processes speech and generates responses:
* **STT Model** — select the Speech-to-Text model for voice input
* **STT Platform** — choose the Speech-to-Text provider (e.g., Assembly)
* **TTT Model** — select the language model for text processing (e.g., GPT-4)
* **TTT Platform** — choose the text processing provider (e.g., OpenAI)
* **TTS Model** — select the Text-to-Speech voice model (e.g., eleven\_turbo\_v2)
* **TTS Platform** — choose the Text-to-Speech provider (e.g., ElevenLabs)
* **Play Filler Audios** — toggle filler sounds while the Agent processes speech
* **Auto Sleep** — enable automatic sleep mode after inactivity
* **Auto Sleep Timeout** — set seconds of silence before sleep mode activates
* **FuzzyWuzzy Threshold** — adjust text-matching sensitivity (higher = stricter)
* **OpenAI TTT Temperature** — control response randomness (0 = focused, 1 = creative)
* **OpenAI TTT Frequency Penalty** — reduce word repetition in responses
* **OpenAI TTT Presence Penalty** — keep responses on-topic and relevant
* **Voice Similarity Boost** — adjust how closely the voice matches its intended sound
* **Voice Stability** — control voice consistency (higher = more consistent)
### App
**Appearance**
* **System** — match your device's system theme
* **Dark** — use dark mode
* **Light** — use light mode
**Support & Information**
* **Help & Support** — access help resources and contact support
* **About** — view app version and information
**Account Actions**
* **Sign Out** — log out of your OpenHome account
* **Delete Account** — permanently delete your account and data
## Looking for More Control?
OpenHome also offers a comprehensive [Web Dashboard](/dashboard) where you can build Agents, create Abilities, manage your account, and access advanced features beyond what's available in the OpenHome - Voice AI DevKit App.
## See also
* [Web Dashboard](/dashboard) — manage your Agents, Abilities, and account from the web
* [Agents](/agents) — learn how voice Agents work on the OpenHome DevKit
* [Abilities](/ability) — extend your Agent with custom Python-based skills
* [Local Ability](/guides/getting-started/local-ability) — build Abilities that run directly on the OpenHome DevKit hardware
# Onboard DevKit with OpenHome - Voice AI DevKit App
Source: https://docs.openhome.com/devkit/devkit-onboarding-app
Set up your OpenHome DevKit with the OpenHome - Voice AI DevKit App on your iOS device.
Don't have an iOS device? You can onboard your OpenHome DevKit using the Terminal instead. See [Onboard OpenHome DevKit via Terminal](/devkit/devkit-setup-terminal).
## Prerequisites
Before starting, ensure you have the following:
* An OpenHome account at [app.openhome.com](https://app.openhome.com)
* An iPhone running iOS 17.0 or later
* Your home Wi-Fi name and password
Download on the App Store
## Onboarding Steps
**Get Started**
* Turn on your OpenHome DevKit and open the **OpenHome - Voice AI DevKit App** on your iPhone. Tap **Get Started** to begin the setup.
**Find and Connect Your DevKit**
* The app will scan for your OpenHome DevKit and list your device. Select your device from the list and tap **Connect Device**.
* The app will show **Device Connected** and you will hear a sound from your DevKit.
* If you didn't hear a sound, tap **Try Again**.
* If you heard it, tap **Yes, I heard it**.
**Connect to Wi-Fi**
* Select your home Wi-Fi network from the list and enter the password. This network will be connected to your DevKit. Once connected, you will hear a confirmation sound from your DevKit.
* Once connected to Wi-Fi, tap **Continue**.
**Setup your OpenHome Account**
* Sign in to your OpenHome account using **Continue with Apple**, **Continue with Google**, or **Sign in with Email**. If you don't have an OpenHome account, sign up at [app.openhome.com](https://app.openhome.com/).
* Once signed in, the app will move to configuring the device. When configuration is complete, you will hear a sound from the DevKit, and then the Agent will speak its starting message.
* The Agent on the DevKit will speak its welcome message, and your DevKit will appear as connected on the app dashboard.
Now your OpenHome DevKit is set up and ready to use. Explore the [OpenHome - Voice AI DevKit App](/devkit/devkit-companion-app-dashboard) to manage your DevKit, switch Agents, and configure your settings.
## See also
* [OpenHome - Voice AI DevKit App Overview](/devkit/devkit-companion-app-dashboard) — full overview of the OpenHome - Voice AI DevKit App
* [Agents](/agents) — learn how voice Agents work on the OpenHome DevKit
* [Abilities](/ability) — extend your Agent with custom Python-based skills
* [Local Ability](/guides/getting-started/local-ability) — build Abilities that run directly on the OpenHome DevKit hardware
# Setup OpenHome OS on Your Own Raspberry Pi
Source: https://docs.openhome.com/devkit/devkit-setup-raspberry-pi
Flash OpenHome OS on your own Raspberry Pi hardware.
This guide is for developers setting up OpenHome on their own Raspberry Pi who do not have an official OpenHome DevKit.
## Prerequisites
* **Raspberry Pi** (Zero 2 W, Pi 4, or Pi 5)
* **SD card** (8GB minimum)
* **USB card reader** or adapter
* **Your Pi's recommended charger**
* **Bluetooth speaker**
* **USB microphone**
* **Computer** with internet access
**Interruption is not supported on custom Raspberry Pi hardware.** The Agent cannot be interrupted while speaking (TTS) or during music mode. Plan your Ability flows accordingly and handle these states gracefully when building.
## Step 1: Download Required Software
* Download **Raspberry Pi Imager** from the official [Raspberry Pi website](https://www.raspberrypi.org/software/) for your operating system and install it.
* Download the **OpenHome DevKit OS** image from [here](https://drive.google.com/file/d/1yXt5IgmZ9X8lGFjZ4Ib9ZFGbaRkJXASk/view?usp=drive_link).
## Step 2: Burn the OpenHome Image to the SD Card
* Insert your SD card into the USB card reader and connect it to your computer.
* Open **Raspberry Pi Imager**, click **Choose Device** and select your Raspberry Pi model.
* Click **Choose Operating System** → **Use custom** → select the downloaded OpenHome image file.
* Click **Choose Storage** and select your SD card.
* Click **Next**. When prompted, click `No` and continue.
* Click **Write** to burn the image. This may take a few minutes.
## Step 3: Set Up the Raspberry Pi
* Insert the SD card into your Raspberry Pi.
* Connect your Raspberry Pi to power and turn it on.
## Step 4: Create a New User on OpenHome
* If you don't have an account, create one at [app.openhome.com](https://app.openhome.com).
* If you already have an account but no password set, use **Forgot Password** or set one from **Profile → Settings**.
## Step 5: Connect to OpenHome Wi-Fi
* Connect your device to the Wi-Fi network named `OpenHome_MACADDRESS`.
* The setup page should open automatically. If it doesn't, open a browser and go to [http://192.168.50.1](http://192.168.50.1).
* Follow the on-screen instructions to configure your Wi-Fi and log in with your OpenHome credentials.
* Once complete, you will see a success status.
## Step 6: Connect to Your Internet Wi-Fi
* Disconnect from `OpenHome_MACADDRESS` and reconnect to your regular home Wi-Fi.
* Go to [app.openhome.com](https://app.openhome.com) and log in with the same account.
## Step 7: Configure Bluetooth Speaker
* Navigate to **Profile → Settings → DevKit**. Your device should be listed as connected.
* Turn on Bluetooth on your speaker.
* Click **Scan Bluetooth Devices** in the OpenHome app and select your speaker from the list.
* Ensure the profile is set to **a2dp-sink**. If it doesn't appear, try reconnecting.
* Connect your USB microphone to the Raspberry Pi and set the default input to **analog-mono**.
You should see two connected devices: a mic and a speaker. Known limitation (current OpenHome Pi Zero image): interruption is not supported yet.
* The call should start automatically. If it doesn't, click **Restart Agent**.
## Setup Complete
Your OpenHome OS is now set up on your Raspberry Pi. You can now enjoy OpenHome on your Raspberry Pi.
# Onboard OpenHome DevKit via Terminal
Source: https://docs.openhome.com/devkit/devkit-setup-terminal
Set up your OpenHome DevKit from your Mac, Linux, or Windows terminal.
This guide is for users without an iPhone. If you have an iOS device, we recommend the [OpenHome - Voice AI DevKit App](/devkit/devkit-onboarding-app) for the easiest setup.
## Prerequisites
* **Python 3.8 or later** installed on your computer
* **Bluetooth enabled** on your computer
* Your home Wi-Fi name and password
* An OpenHome account at [app.openhome.com](https://app.openhome.com)
## Step 1: Install the Required Library
Open your terminal and run:
```bash theme={"system"}
pip install bleak
```
## Step 2: Download the OpenHome Client
Download the OpenHome Client script
Save the file to a folder on your computer.
## Step 3: Run the OpenHome Client
In your terminal, navigate to the folder where you saved `openhome_client.py` and run:
```bash theme={"system"}
python openhome_client.py
```
You will see a menu with numbered options. At the prompt `Select option (0-11):`, type the option number and press Enter. Run the steps below in order.
### 1. Scan for Your DevKit
Enter `1` to run **Scan for openhome device**.
The OpenHome Client will scan for nearby devices and automatically pick the one with the `openhome` prefix.
### 2. Connect to Your DevKit
Enter `2` to run **Connect to device**.
The OpenHome Client will establish a connection and confirm when connected.
### 3. Scan for Wi-Fi Networks
Enter `3` to run **Request WiFi scan**.
This will scan Wi-Fi networks on the DevKit and display a list of available networks.
### 4. Display Wi-Fi Networks
Enter `4` to run **Display WiFi networks** and view the list of available networks.
### 5. Connect to Your Wi-Fi
Enter `5` to run **Connect to a WiFi network**.
* Enter the number of your home Wi-Fi network from the list.
* Enter your Wi-Fi password when prompted.
The OpenHome Client will send the credentials to your DevKit and wait for a connection confirmation.
### 6. Verify Connection Status
Enter `6` to run **Read WiFi status** and confirm your DevKit is connected to Wi-Fi.
Options `7` and `8` enable/disable real-time WiFi status notifications from the DevKit — useful for debugging connection issues by streaming state changes live. Option `9` reads the current API key status. You can skip these for basic setup and proceed to step 10.
### 10. Set Your OpenHome API Key
Enter `10` to run **Set API key** and enter your API key when prompted.
You can find your API key in the OpenHome dashboard under [Profile → Settings → API Keys](https://app.openhome.com/dashboard/settings).
Once the key is set, your Agent will speak its welcome message. You can verify the API key status at any time by entering `9` to run **Read API key status**.
## Setup Complete
Your OpenHome DevKit is now set up and available in your dashboard under [DevKit](https://app.openhome.com/dashboard/devkit).
## Need Help?
Ran into an error during setup? Reach out to us on Discord and the OpenHome team will help you out.
## See also
* [Abilities](/ability) — extend your Agent with custom Python-based skills
* [Agents](/agents) — learn how voice Agents work on the OpenHome DevKit
* [Local Ability](/guides/getting-started/local-ability) — build Abilities that run directly on the OpenHome DevKit hardware
* [OpenHome - Voice AI DevKit App Overview](/devkit/devkit-companion-app-dashboard) — full overview of the OpenHome - Voice AI DevKit App
# OpenHome DevKit Reflash Guide
Source: https://docs.openhome.com/devkit/flash-openhome-os-on-devkit
Reflash the OpenHome OS image on your DevKit to restore it to a clean working state.
Every OpenHome DevKit ships pre-flashed with OpenHome OS on its included SD card. If your DevKit runs into an issue that prevents normal operation — for example:
* A firmware upgrade fails or gets stuck partway through.
* A factory reset fails or leaves the device in a broken state.
* The device becomes unresponsive or fails to boot.
* The Agent stops responding to voice and standard troubleshooting does not recover it.
…reflashing the SD card with a fresh OpenHome OS image will restore the device to a clean working state.
## Prerequisites
Before you start, make sure you have:
* **An SD card** — the SD card included with your DevKit is recommended, or a spare card (**16 GB minimum**).
* **An SD card reader** for your computer.
* **[Raspberry Pi Imager](https://www.raspberrypi.com/software/)** installed on your computer.
* **The OpenHome OS image**, downloaded from the link in Step 1 below.
## Step 1: Download the OpenHome OS image
Download the latest OpenHome OS image from the OpenHome Drive folder. Save the file somewhere you can find it — you will select it from Raspberry Pi Imager in Step 3.
## Step 2: Prepare the SD card
If you are reusing the SD card from your DevKit:
1. Power off your DevKit completely and wait for it to fully shut down.
2. Carefully eject the SD card from the DevKit's SD card slot.
3. Insert the SD card into your computer using the SD card reader.
If you are using a spare SD card (16 GB or larger), insert it into your computer using the SD card reader instead.
## Step 3: Flash the image with Raspberry Pi Imager
1. Open **Raspberry Pi Imager**.
2. Click **Choose OS** → **Use custom** → select the OpenHome OS image you downloaded in Step 1.
3. Click **Choose Storage** → select the SD card from your DevKit.
4. Click **Next**. When prompted to apply OS customisation settings, click **No** and continue.
5. Click **Write** to start flashing. Confirm when prompted.
6. Wait for the flashing and verification to complete. Do not remove the SD card while this is in progress.
7. When Raspberry Pi Imager reports success, safely eject the SD card from your computer.
Raspberry Pi Imager with the OpenHome OS image and SD card selected:
When prompted to apply OS customisation settings, click **No**:
## Step 4: Insert the SD card and boot the DevKit
1. Insert the freshly flashed SD card back into your DevKit's SD card slot.
2. Power on the DevKit.
3. Wait for the device to complete its first boot — this may take a minute.
## Step 5: Re-onboard with the DevKit App
Reflashing wipes the device's local configuration, so the DevKit needs to be onboarded again as if it were new. Follow the standard setup flow in the [DevKit Onboarding App guide](/devkit/devkit-onboarding-app) to:
* Connect the DevKit to your Wi-Fi network.
* Sign in to your OpenHome account.
* Restore your Agents and installed Abilities.
## Need help?
If you run into any issues while flashing or booting the DevKit afterwards, reach out on the OpenHome Discord and the team will help you out.
Ask the team and the community in #dev-help on Discord.
# Get started
Source: https://docs.openhome.com/devkit/home-assistant/get-started
Turn your OpenHome DevKit into a smart home hub with Home Assistant, installed and managed right from the dashboard.
Home Assistant turns your DevKit into a smart home hub. Install it from the OpenHome dashboard with no terminal, then control lights, sensors, thermostats, and thousands of other devices. You can even operate them by voice through your agent.
Once installed, it starts automatically on boot, connects to OpenHome out of the box, and arrives pre-configured with your timezone and location.
**Requires the Home Assistant 64-bit firmware.** Your DevKit must be running the **Home Assistant 64-bit** firmware before you can install Home Assistant. Update it from the DevKit's [Firmware settings](/devkit/devkit-companion-app-dashboard#firmware), then come back here.
## Where to next
Install, update, and uninstall Home Assistant from the dashboard.
Open the dashboard and add any integration you like.
Build an Ability to run your devices with spoken commands.
# Manage
Source: https://docs.openhome.com/devkit/home-assistant/manage
Install, update, and uninstall Home Assistant from the OpenHome dashboard.
You manage Home Assistant from the **Home Assistant** section of the OpenHome dashboard, which shows the live status of your instance (**Installing**, **Installed**, or **Running**) alongside three operations. The buttons change with state: before anything is installed you see only **Install**, and once it's installed you see **Update** and **Uninstall**. Each runs in the background, and its button is disabled while it works.
Sets up Home Assistant and connects it to OpenHome automatically, including a user, your detected timezone and location, and the link back to OpenHome.
Updates to the latest compatible version if one is available. If you're already current, it changes nothing.
Cleanly removes Home Assistant and all its data, leaving the rest of your device untouched.
**Keep your DevKit powered on while an operation runs.** Install, update, and uninstall happen on the device and can take several minutes. Powering off or rebooting mid-operation can leave Home Assistant in a broken state. Wait until the button re-enables before powering down.
## Install
Installation is hands-off, with no prompts and no terminal. Make sure your DevKit is on the **Home Assistant 64-bit** firmware first (see [Get started](/devkit/home-assistant/get-started)), then:
In the OpenHome dashboard, find the **Home Assistant** section. It shows the current status next to the Home Assistant icon, **Installing**, **Installed**, or **Running**, so you always know what state your instance is in.
Press **Install**. The status switches to **Installing** while OpenHome sets up Home Assistant on the device, creating a user, detecting your location, setting your timezone, currency, and units, and wiring up the connection to OpenHome.
When the status reads **Installed** and then **Running**, your instance is up. The **Install** button is replaced by **Update** and **Uninstall** once the operation completes. Keep the DevKit powered on the whole time.
Once it's installed, Home Assistant starts automatically on boot. Next, [open the dashboard and add your devices](/devkit/home-assistant/use).
### Install time
| Scenario | Time |
| ------------------------------ | --------------- |
| First-time install | 8 to 10 minutes |
| Reinstall (after an uninstall) | about 3 minutes |
The first install downloads everything Home Assistant needs, so it takes longer. Later installs reuse what's already on the device and finish faster.
## Update
OpenHome checks for the newest compatible version. If one is available it updates and reports success; if you're already current it tells you and changes nothing. Your running Home Assistant keeps working throughout, and is only replaced once the new version is ready.
## Uninstall
Uninstall removes Home Assistant and everything it set up, and leaves the rest of your device untouched. Afterward the **Install** button reappears, and reinstalling is faster because the packages it needs are already on the device.
# Talk to your home
Source: https://docs.openhome.com/devkit/home-assistant/talk-to-your-home
Once a device is in Home Assistant, build an Ability that lets you run it with spoken commands.
Connect a device to Home Assistant once, and you can control it just by talking to your agent. No app, no buttons. Say what you want, and it happens.
*"Turn on the kitchen light."*
*"Dim it to twenty percent."*
*"Set the thermostat to seventy."*
*"Lock the front door."*
*"Start movie night."*
## How it works
Add it from the dashboard, as covered in [Add integrations](/devkit/home-assistant/use#add-integrations).
Create a small Ability that reaches the device through Home Assistant on your DevKit.
Trigger the Ability by voice and run the device with natural commands.
This has to be a **[Local Ability](/building-abilities/local-ability)**. The action happens on your DevKit, where Home Assistant runs, so only the Local category can reach it.
New to building Abilities? Start with the [Local Ability quickstart](/guides/getting-started/local-ability).
## Example: voice-control Tasmota lights
This walkthrough builds a working voice integration end to end: a [Local Ability](/building-abilities/local-ability) that controls **Tasmota** smart lights through the Home Assistant instance on your device. Once it's set up you can say *"turn on the kitchen light"*, *"make it warm"*, or *"set it to red"* and your agent does the rest.
It comes in two files, which is the standard Local Ability split:
| File | Runs on | Responsibility |
| --------------------- | ------------------------ | ----------------------------------------------------------------------------------------- |
| `main.py` | Standard Ability runtime | Listens to the user, turns speech into an intent with the LLM, and calls the DevKit side. |
| `devkit_functions.py` | OpenHome DevKit | Talks to Home Assistant's REST API on the device and performs the actual light action. |
This is a **Local Ability**, so it only runs on real OpenHome DevKit hardware, the device where Home Assistant is installed. See [Local Abilities](/building-abilities/local-ability) for the full reference on how the two files work together.
### What you'll need
* Home Assistant installed on your DevKit (see [Install & manage](/devkit/home-assistant/manage)). The MQTT integration is set up automatically during install.
* One or more **Tasmota** smart bulbs or strips on the same network as your DevKit.
* The OpenHome Live Editor to create the Ability under the **Local** category.
There are three steps, and the first is automatic. The DevKit already connects to Home Assistant for you, so in practice you just pair your Tasmota device with Home Assistant and build the Ability.
### Step 1: Connecting to Home Assistant (automatic)
You don't set up authentication yourself. When you install Home Assistant through OpenHome, the credentials your Ability needs are provisioned on the device, and the DevKit side authenticates on its own. There's no file to create, no token to paste, and nothing to configure. For this Tasmota example, it just works.
#### Reuse the connection in your own Ability
Building a different Ability that talks to Home Assistant? Reuse OpenHome's connection logic directly. Drop this into your `devkit_functions.py` and call `_ensure_auth()` before any Home Assistant request. It finds the saved credentials, exchanges them for a short-lived access token at Home Assistant's `/auth/token` endpoint, and caches the token until just before it expires:
```python theme={"system"}
DEVKIT_AUTH_PATHS = [
os.environ.get("OPENHOME_HA_AUTH_PATH", ""),
os.path.expanduser("~/.ha_refresh_token"),
"/home/openhome/.ha_refresh_token",
"/root/.ha_refresh_token",
]
_AUTH = {
"url": None,
"client_id": None,
"refresh_token": None,
"access_token": None,
"exp": 0,
}
def _ensure_auth():
if not _AUTH["refresh_token"]:
path = None
for p in DEVKIT_AUTH_PATHS:
if p and os.path.exists(p) and os.access(p, os.R_OK):
path = p
break
if not path:
log.warning("[HA] no refresh token file found")
return None, None
try:
values = {}
with open(path, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
values[k.strip()] = v.strip().strip('"\'')
except Exception as exc:
log.error(f"[HA] read {path}: {exc}")
return None, None
url = (values.get("HA_URL") or "").rstrip("/")
rt = values.get("HA_REFRESH_TOKEN")
cid = values.get("HA_CLIENT_ID") or (url + "/" if url else None)
if not url or not rt:
log.warning("[HA] auth file missing HA_URL or HA_REFRESH_TOKEN")
return None, None
_AUTH["url"] = url
_AUTH["refresh_token"] = rt
_AUTH["client_id"] = cid
if _AUTH["access_token"] and time.time() < _AUTH["exp"]:
return _AUTH["url"], _AUTH["access_token"]
data = urllib.parse.urlencode({
"grant_type": "refresh_token",
"refresh_token": _AUTH["refresh_token"],
"client_id": _AUTH["client_id"],
}).encode()
req = urllib.request.Request(
f"{_AUTH['url']}/auth/token", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
body = json.loads(resp.read().decode("utf-8", errors="replace"))
except urllib.error.HTTPError as e:
if e.code == 400:
_AUTH["refresh_token"] = None
log.error(f"[HA] /auth/token HTTP {e.code}")
return _AUTH["url"], None
except Exception as exc:
log.error(f"[HA] /auth/token failed: {exc}")
return _AUTH["url"], None
access_token = body.get("access_token")
expires_in = int(body.get("expires_in") or 1800)
if not access_token:
return _AUTH["url"], None
_AUTH["access_token"] = access_token
_AUTH["exp"] = time.time() + expires_in - 60
return _AUTH["url"], access_token
```
Every Home Assistant request goes through `_ensure_auth()` first, so the access token is fetched and refreshed for you automatically. If a token expires mid-session, the next call refreshes it. (See `_ha_request()` in the full `devkit_functions.py` below for how each call uses it.)
#### Where the credentials live (reference)
You normally never touch this, but for advanced or custom setups it helps to know how the connection is stored. `_ensure_auth()` reads a small file containing:
```
HA_URL=http://localhost:8123
HA_REFRESH_TOKEN=your_home_assistant_refresh_token
HA_CLIENT_ID=http://localhost:8123/
```
| Key | Value |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| `HA_URL` | The Home Assistant address **as seen from the DevKit**. Because Home Assistant runs on the same device, `http://localhost:8123` works. |
| `HA_REFRESH_TOKEN` | A refresh token issued by Home Assistant for user (`openhome_devkit_user`, created during install). |
| `HA_CLIENT_ID` | The OAuth client id the token was issued for. Defaults to `HA_URL` + `/` if you leave it out. |
It checks these locations in order and uses the first one it finds:
1. The path in the `OPENHOME_HA_AUTH_PATH` environment variable
2. `~/.ha_refresh_token`
3. `/home/openhome/.ha_refresh_token`
OpenHome saves these credentials during install, so this file already exists at one of the paths above, and this is simply where the code looks. For a custom location, point `OPENHOME_HA_AUTH_PATH` at your own file.
Treat this file like a password, since anyone with the refresh token can control your Home Assistant. Keep it readable only by your user, and never commit it into an Ability or share it.
### Step 2: Connect your Tasmota device to Home Assistant
Now pair the physical light with Home Assistant. Tasmota talks to the same MQTT broker your DevKit's Home Assistant already uses, so once it connects, Home Assistant discovers it on its own.
Check your router's list of connected devices, or open `tasmota-XXXXX.local` in a browser.
Set these values:
| Field | Value |
| ---------- | ----------------------------------------------- |
| Host | Your DevKit's IP (for example `192.168.18.106`) |
| Port | `1883` |
| User | `openhome_devkit_user` |
| Password | `admin123` |
| Topic | leave default |
| Full Topic | leave default (`%prefix%/%topic%/`) |
The **Host** is your DevKit's IP address, the same one you use to open the Home Assistant dashboard. You can find it in the **DevKit** section under **MQTT**, shown as **Device IP**.
Tasmota restarts and connects to the broker.
The MQTT integration is already loaded in Home Assistant. Once Tasmota connects, it publishes to the discovery topic and Home Assistant's Tasmota integration picks it up automatically, with no manual configuration needed. The light then shows up as a `light.*` entity, which is exactly what the Ability controls.
### Step 3: Build the Ability
In the OpenHome Live Editor, create a new Ability and select the **Local** category (Local Abilities are the only type that can reach Home Assistant on the device; see [Local Abilities](/building-abilities/local-ability)). Give it trigger words so users can launch it by voice, such as *"smart home"* or *"lights"*.
A Local Ability is two files. This example needs no third-party packages, so `requirements.txt` can stay empty, since everything uses the Python standard library.
#### `main.py`: voice and intent
`main.py` runs in the standard Ability runtime. It greets the user, runs a conversation loop, and uses the LLM to convert each spoken request into a structured intent (which integration, which action, which device). It then hands that intent to the DevKit side and speaks a short confirmation.
````python theme={"system"}
import json
import re
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
def _tasmota_light_context():
return (
"=== TASMOTA LIGHTS (integration: tasmota_light) ===\n"
"Smart bulbs/strips controlled via Tasmota over MQTT. The user may have "
"one or more. Set `hint` to a short natural-language descriptor of which "
"device (e.g. 'kitchen', 'bedroom lamp', 'the bulb'). Leave hint empty if "
"only one is plausible from context — the DevKit will pick the only one.\n"
"Actions:\n"
" on_off params={\"state\":\"on\"|\"off\"|\"toggle\"}\n"
" brightness params={\"percent\":0-100} (\"bright\"=90, \"dim\"=20, \"half\"=50)\n"
" color params={\"rgb\":[R,G,B]} (0-255 each; convert any color name yourself)\n"
" color_temp params={\"kelvin\":2000-6500} (cozy=2700, reading=4000, daylight=6000)"
)
def _tasmota_light_execute(action, params, hint):
p = params or {}
h = (hint or "").strip()
if action == "on_off":
state = (p.get("state") or "on").lower()
if state == "on":
service = "turn_on"
elif state == "off":
service = "turn_off"
else:
service = "toggle"
return ("tasmota_light_action", [service, h, "{}"])
if action == "brightness":
try:
pct = float(p.get("percent", 50))
except Exception:
return None
b = max(0, min(255, int(round(pct * 2.55))))
return ("tasmota_light_action", ["turn_on", h, json.dumps({"brightness": b})])
if action == "color":
rgb = p.get("rgb") or [255, 255, 255]
try:
rgb = [max(0, min(255, int(c))) for c in list(rgb)[:3]]
except Exception:
return None
if len(rgb) != 3:
return None
return ("tasmota_light_action", ["turn_on", h, json.dumps({"rgb_color": rgb})])
if action == "color_temp":
try:
k = max(2000, min(6500, int(p.get("kelvin", 4000))))
except Exception:
return None
return ("tasmota_light_action", ["turn_on", h, json.dumps({"color_temp_kelvin": k})])
return None
# Integration registry
INTEGRATION_REGISTRY = {
"tasmota_light": {
"context": _tasmota_light_context,
"execute": _tasmota_light_execute,
},
}
# Prompt builder
def _build_system_prompt():
blocks = "\n\n".join(
handler["context"]() for handler in INTEGRATION_REGISTRY.values()
)
return f"""You are a voice assistant for a smart home. Your output is read aloud by TTS to a native US English speaker.
Return ONLY valid JSON (no markdown, no code fences):
{{
"integration": "",
"action": "",
"params": {{ ... action-specific ... }},
"hint": "",
"spoken_response": ""
}}
TTS RULES (apply to spoken_response):
- Plain spoken English. No markdown, asterisks, dashes, bullets, emojis, URLs, code, symbols.
- No abbreviations. Spell them out: "for example" not "e.g.", "by the way" not "FYI".
- Numbers spoken naturally: "thirty" not "30" when natural.
- No brand or technical jargon. Never say "Tasmota", "entity", "service", "RGB", "kelvin".
- Don't restate the user's command. Just confirm and stop.
LENGTH RULES:
- On/off: under 6 words. ("On it." / "Lights out.")
- Brightness/color/temp: under 10 words. ("Going warm." / "Setting it to red.")
- end_session: under 6 words. ("Catch you later." / "Anytime.")
INTENT RULES:
1. Return ONLY valid JSON.
2. integration MUST be one of the loaded names (or "none").
3. Only use actions listed for the chosen integration.
4. "it"/"that"/"the light" in follow-ups = previously hinted device. Reuse the same hint.
5. Goodbye, "I'm done", "that's all", "never mind", "thanks bye", silence-style closers -> action="end_session".
6. We CANNOT read the device's actual current state. NEVER claim it is on or off without a fresh action.
7. Unrelated request -> integration="none", action="none", short polite reply.
AVAILABLE INTEGRATIONS:
{blocks}
"""
REPROMPTS = ["Sorry?", "One more time?", "Didn't catch that.", "Try saying turn it on or change the color."]
class AgentAbilityCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
#{{register capability}}
def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self.worker)
self.worker.session_tasks.create(self.run())
async def run(self):
try:
try:
await self.capability_worker.send_devkit_capability_action(
function_name="ha_startup", args=[], timeout=15,
)
except Exception as exc:
self.worker.editor_logging_handler.error(
f"[HA] ha_startup ping failed: {exc}"
)
system_prompt = _build_system_prompt()
await self.capability_worker.speak("Smart home ready. What can I do?")
history = []
last_hint = ""
miss_count = 0
while True:
msg = await self.capability_worker.user_response()
if not msg or not msg.strip():
prompt = REPROMPTS[min(miss_count, len(REPROMPTS) - 1)]
await self.capability_worker.speak(prompt)
miss_count += 1
if miss_count >= 4:
await self.capability_worker.speak("Talk later.")
break
continue
miss_count = 0
hint_note = f" (last device hint: {last_hint})" if last_hint else ""
user_prompt = f'User said: "{msg}"{hint_note}'
raw = self.capability_worker.text_to_text_response(
user_prompt, history, system_prompt=system_prompt,
)
intent = self._parse_json(raw)
if intent is None:
await self.capability_worker.speak("Try that again?")
continue
history.append({"role": "user", "content": user_prompt})
history.append({"role": "assistant", "content": raw or ""})
if len(history) > 20:
history = history[-20:]
hint_val = (intent.get("hint") or "").strip()
if hint_val:
last_hint = hint_val
if intent.get("action") == "end_session":
await self.capability_worker.speak(
intent.get("spoken_response") or "Bye."
)
break
await self._dispatch(intent)
except Exception as exc:
self.worker.editor_logging_handler.error(f"[HA] run error: {exc}")
await self.capability_worker.speak("Something didn't work right.")
finally:
self.capability_worker.resume_normal_flow()
async def _dispatch(self, intent):
integration = intent.get("integration", "")
action = intent.get("action", "")
params = intent.get("params") or {}
hint = (intent.get("hint") or "").strip()
spoken = intent.get("spoken_response") or "Done."
if integration in ("none", "") or action in ("none", ""):
await self.capability_worker.speak(spoken)
return
handler = INTEGRATION_REGISTRY.get(integration)
if not handler:
await self.capability_worker.speak("I can't do that one.")
return
translated = handler["execute"](action, params, hint)
if not translated:
await self.capability_worker.speak("Can't do that one.")
return
devkit_fn, devkit_args = translated
try:
await self.capability_worker.send_devkit_capability_action(
function_name=devkit_fn, args=devkit_args, timeout=10,
)
except Exception as exc:
self.worker.editor_logging_handler.error(
f"[HA] devkit call {devkit_fn} failed: {exc}"
)
await self.capability_worker.speak(spoken)
@staticmethod
def _parse_json(text):
if not text:
return None
try:
cleaned = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", text.strip())
return json.loads(cleaned)
except Exception:
return None
````
#### `devkit_functions.py`: Home Assistant on the device
`devkit_functions.py` runs on the DevKit. It authenticates with the credentials file from Step 1, finds the matching Tasmota light entity, and calls Home Assistant's `light` services to perform the action. `ha_startup` is a lightweight ping that also reports which integrations and how many entities are available.
```python theme={"system"}
import os
import sys
import json
import time
import urllib.parse
import urllib.request
import urllib.error
try:
from devkit_utils.devkit_logging import web_logger as log
except Exception:
class _StubLogger:
def info(self, *a, **k): pass
def warning(self, *a, **k): pass
def error(self, *a, **k): pass
def debug(self, *a, **k): pass
log = _StubLogger()
log.info("[HA devkit] module loaded")
REQUEST_TIMEOUT = 8
DEVKIT_AUTH_PATHS = [
os.environ.get("OPENHOME_HA_AUTH_PATH", ""),
os.path.expanduser("~/.ha_refresh_token"),
"/home/openhome/.ha_refresh_token",
"/root/.ha_refresh_token",
]
_AUTH = {
"url": None,
"client_id": None,
"refresh_token": None,
"access_token": None,
"exp": 0,
}
_NON_TASMOTA_LIGHT_PLATFORMS = ("hue", "lifx", "shelly", "wled", "yeelight", "tplink")
def _ensure_auth():
if not _AUTH["refresh_token"]:
path = None
for p in DEVKIT_AUTH_PATHS:
if p and os.path.exists(p) and os.access(p, os.R_OK):
path = p
break
if not path:
log.warning("[HA] no refresh token file found")
return None, None
try:
values = {}
with open(path, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
values[k.strip()] = v.strip().strip('"\'')
except Exception as exc:
log.error(f"[HA] read {path}: {exc}")
return None, None
url = (values.get("HA_URL") or "").rstrip("/")
rt = values.get("HA_REFRESH_TOKEN")
cid = values.get("HA_CLIENT_ID") or (url + "/" if url else None)
if not url or not rt:
log.warning("[HA] auth file missing HA_URL or HA_REFRESH_TOKEN")
return None, None
_AUTH["url"] = url
_AUTH["refresh_token"] = rt
_AUTH["client_id"] = cid
if _AUTH["access_token"] and time.time() < _AUTH["exp"]:
return _AUTH["url"], _AUTH["access_token"]
data = urllib.parse.urlencode({
"grant_type": "refresh_token",
"refresh_token": _AUTH["refresh_token"],
"client_id": _AUTH["client_id"],
}).encode()
req = urllib.request.Request(
f"{_AUTH['url']}/auth/token", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
body = json.loads(resp.read().decode("utf-8", errors="replace"))
except urllib.error.HTTPError as e:
if e.code == 400:
_AUTH["refresh_token"] = None
log.error(f"[HA] /auth/token HTTP {e.code}")
return _AUTH["url"], None
except Exception as exc:
log.error(f"[HA] /auth/token failed: {exc}")
return _AUTH["url"], None
access_token = body.get("access_token")
expires_in = int(body.get("expires_in") or 1800)
if not access_token:
return _AUTH["url"], None
_AUTH["access_token"] = access_token
_AUTH["exp"] = time.time() + expires_in - 60
return _AUTH["url"], access_token
def _ha_request(method, path, body=None, timeout=REQUEST_TIMEOUT):
url, token = _ensure_auth()
if not url or not token:
return 0, "auth unavailable"
data = json.dumps(body).encode("utf-8") if body is not None else None
for attempt in (1, 2):
req = urllib.request.Request(
url + path, data=data, method=method,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
text = resp.read().decode("utf-8", errors="replace")
return resp.status, text
except urllib.error.HTTPError as e:
if e.code == 401 and attempt == 1:
log.warning("[HA] <- 401, refreshing token")
_AUTH["access_token"] = None
_AUTH["exp"] = 0
_, token = _ensure_auth()
if not token:
return 401, "auth refresh failed"
continue
text = ""
try:
text = e.read().decode("utf-8", errors="replace")
except Exception:
pass
log.warning(f"[HA] <- HTTP {e.code}")
return e.code, text
except Exception as e:
log.error(f"[HA] {type(e).__name__}: {e}")
return 0, str(e)
return 0, "exhausted retries"
def _emit(data):
payload = json.dumps(data, separators=(",", ":"))
sys.stdout.write(payload + "\n")
try:
sys.stdout.flush()
except Exception:
pass
return data
def _pick_entity(candidates, name_hint):
if not candidates:
return None
needle = (name_hint or "").strip().lower()
if not needle:
return candidates[0].get("entity_id")
for s in candidates:
attrs = s.get("attributes") or {}
fn = (attrs.get("friendly_name") or "").lower()
eid = s.get("entity_id", "").lower()
if needle in fn or needle in eid:
return s.get("entity_id")
for s in candidates:
attrs = s.get("attributes") or {}
fn = (attrs.get("friendly_name") or "").lower()
eid = s.get("entity_id", "").lower()
for word in needle.split():
if word and (word in fn or word in eid):
return s.get("entity_id")
return candidates[0].get("entity_id")
# DevKit functions
def ha_startup():
url, token = _ensure_auth()
if not url or not token:
return _emit({"ok": False, "err": "auth unavailable"})
status, _ = _ha_request("GET", "/api/")
if status != 200:
return _emit({"ok": False, "err": f"HA ping HTTP {status}"})
integrations = []
status, body = _ha_request("GET", "/api/config/config_entries/entry", timeout=10)
if status == 200:
try:
entries = json.loads(body)
integrations = sorted({e.get("domain") for e in entries if e.get("domain")})
except Exception as exc:
log.warning(f"[HA] config_entries parse: {exc}")
entities = 0
status, body = _ha_request("GET", "/api/states", timeout=15)
if status == 200:
try:
entities = len(json.loads(body))
except Exception as exc:
log.warning(f"[HA] states parse: {exc}")
return _emit({
"ok": True,
"url": url,
"integrations": integrations,
"entities": entities,
})
def tasmota_light_action(service=None, name_hint="", extra_json="{}"):
if not service:
return _emit({"ok": False, "err": "service required"})
status, body = _ha_request("GET", "/api/states", timeout=15)
if status != 200:
return _emit({"ok": False, "err": f"states HTTP {status}"})
try:
states = json.loads(body)
except Exception as exc:
return _emit({"ok": False, "err": f"parse: {exc}"})
candidates = []
for s in states:
if not isinstance(s, dict):
continue
eid = s.get("entity_id", "")
if not eid.startswith("light."):
continue
if s.get("state") in ("unavailable", "unknown"):
continue
attrs = s.get("attributes") or {}
if attrs.get("platform", "") in _NON_TASMOTA_LIGHT_PLATFORMS:
continue
candidates.append(s)
entity_id = _pick_entity(candidates, name_hint)
if not entity_id:
log.warning(f"[HA] no tasmota lights to match hint={name_hint!r}")
return _emit({"ok": False, "err": "no tasmota lights", "hint": name_hint})
try:
extra = json.loads(extra_json) if extra_json and extra_json != "{}" else {}
except Exception:
extra = {}
payload = {"entity_id": entity_id}
payload.update(extra)
status, _ = _ha_request("POST", f"/api/services/light/{service}", body=payload)
return _emit({"ok": status in (200, 201), "status": status, "entity_id": entity_id})
FUNCTION_REGISTRY = {
"ha_startup": ha_startup,
"tasmota_light_action": tasmota_light_action,
}
log.info(f"[HA devkit] registry: {list(FUNCTION_REGISTRY.keys())}")
def list_functions():
print("=" * 60)
print(" HA Devkit Functions (Tasmota Lights)")
print("=" * 60)
for name, func in FUNCTION_REGISTRY.items():
doc = (func.__doc__ or "").strip().split("\n")[0]
print(f" {name}: {doc}")
FUNCTION_REGISTRY["list_functions"] = list_functions
def main():
if len(sys.argv) < 2:
print("Usage: python3 devkit_functions.py [args...]")
sys.exit(1)
func_name = sys.argv[1]
func_args = sys.argv[2:]
if func_name in ("--help", "-h"):
list_functions()
sys.exit(0)
if func_name not in FUNCTION_REGISTRY:
print(f"[error] unknown function '{func_name}'")
sys.exit(1)
try:
FUNCTION_REGISTRY[func_name](*func_args)
sys.exit(0)
except TypeError as e:
print(f"[error] wrong arguments for '{func_name}': {e}")
sys.exit(1)
except Exception as e:
print(f"[error] {e}")
sys.exit(1)
if __name__ == "__main__":
main()
```
When you save in the Live Editor with the DevKit connected, the files sync to the device automatically. See [Sync Local Abilities with the DevKit](/building-abilities/local-ability#sync-local-abilities-with-the-devkit) if your DevKit was offline while editing.
### Step 4: Talk to it
Launch the Ability with one of your trigger words, then speak naturally. The conversation loop keeps listening until you say you're done.
* *"Turn on the kitchen light."*
* *"Make it warm."* (reuses the last light you mentioned)
* *"Set the lamp to red."*
* *"Dim the bedroom to twenty percent."*
* *"That's all, thanks."* (ends the session)
If you have more than one light, include a short descriptor like *"kitchen"* or *"bedroom lamp"*, and the DevKit side matches it against your Home Assistant light names and picks the right one. With a single light, no descriptor is needed.
### Extending it
This template is built around an **integration registry**. To support more than Tasmota lights, add another entry to `INTEGRATION_REGISTRY` in `main.py` (a `context` function describing its actions, and an `execute` function that maps an intent to a DevKit call) and a matching handler in `FUNCTION_REGISTRY` in `devkit_functions.py`. The voice loop, prompt builder, and dispatch logic stay the same.
### Edge cases
The Ability checks for a running Home Assistant on the device when it starts. If Home Assistant isn't installed, or is still starting up, the Ability can't control anything. Make sure it's installed and running (see [Install & manage](/devkit/home-assistant/manage)), then relaunch.
If the Ability says it can't find a light, the Tasmota device probably isn't connected to Home Assistant yet. Recheck Step 2. Once Tasmota connects to the broker, Home Assistant discovers it as a light and the Ability can control it.
This example deliberately ignores lights from other ecosystems (such as Hue, LIFX, Shelly, WLED, Yeelight, and TP-Link) so it only acts on your Tasmota bulbs. To control other brands, extend the Ability as described above.
Add a short descriptor to your command, like *"kitchen"* or *"bedroom lamp"*. The DevKit side matches it against your Home Assistant light names. If it can't match your words to a specific light, it falls back to the first one it finds, so naming your lights clearly in Home Assistant helps.
A bulb that's cut off from power shows as unavailable in Home Assistant and is skipped. Restore power so it reconnects, then try your command again.
The Ability sends actions; it doesn't read a light's current state. It will never claim a light is on or off without performing an action, so ask it to turn things on, off, or toggle rather than asking for status.
If your speech isn't recognized, the Ability asks you to repeat. After a few unclear tries in a row it ends the session politely. Just relaunch it with your trigger word.
### See also
* [Local Abilities](/building-abilities/local-ability): full reference for the `main.py` + `devkit_functions.py` split
* [Local Ability quickstart](/guides/getting-started/local-ability): get a Local Ability running fast
* [Voice-First Best Practices](/guides/best-practices/voice-first): the UX rules behind the spoken responses
# Use it
Source: https://docs.openhome.com/devkit/home-assistant/use
Open the Home Assistant dashboard on your DevKit and add any integration you like.
## Open the dashboard
After installation, the Home Assistant dashboard is available on your network at **`http://:8123`**, where `` is your device's IP address.
Find your device's IP in the **DevKit** section under **MQTT**, shown as **Device IP**. For example, if it is `192.168.18.106`, open `http://192.168.18.106:8123` in your browser.
Sign in with the default credentials:
| Field | Value |
| -------- | ---------------------- |
| Username | `openhome_devkit_user` |
| Password | `admin123` |
Once you're signed in, change the password from your Home Assistant profile for better security.
## Add integrations
Home Assistant supports lights, sensors, media players, thermostats, and thousands of other devices. Go to **Settings → Devices & Services → Add Integration**, search for what you want to connect, and follow the prompts. Anything you add is managed inside Home Assistant.
Want to run these devices by voice? See [Talk to your home](/devkit/home-assistant/talk-to-your-home).
# What is OpenHome DevKit
Source: https://docs.openhome.com/devkit/what-is-devkit
The OpenHome DevKit is an all-in-one hardware and software platform for building custom AI voice experiences.
Design custom voice AI Agents, extend them with Python-based Abilities, and deploy them to dedicated hardware — all from a single platform built for voice-first development.
## What You Can Do
Build Agents with unique personalities, voices, and behaviors tailored to your vision.
Extend your Agent with Python-based skills that unlock new capabilities.
Connect to LEDs, sensors, GPIO pins, and other devices for full hardware control.
Manage your DevKit, switch Agents, and configure everything from the OpenHome - Voice AI DevKit App.
Install Home Assistant on your DevKit from the dashboard and control your smart home by voice.
## Got an OpenHome DevKit?
Onboard your OpenHome DevKit with the OpenHome - Voice AI DevKit App on your iOS device.
Quick setup using the OpenHome - Voice AI DevKit App on iOS. Requires iPhone running iOS 17.0 or later.
Don't have an iOS device? You can onboard your OpenHome DevKit using the Terminal from your Mac, Linux, or Windows computer.
Setup via command line using the OpenHome Client.
## Don't Have an OpenHome DevKit?
Apply to get your own OpenHome DevKit and start building voice AI experiences on dedicated hardware.
Submit your application to get access to an OpenHome DevKit.
Don’t have an official OpenHome DevKit but have your own Raspberry Pi? You can flash OpenHome OS on it and explore the platform today.
Flash OpenHome OS on your own Raspberry Pi (Zero 2 W, Pi 4, or Pi 5).
## Ran Into Trouble With Your DevKit?
If your DevKit is unresponsive, fails a firmware upgrade or factory reset, or can't be recovered through the OpenHome - Voice AI DevKit App, reflashing OpenHome OS on the SD card restores the device to a clean working state.
Reflash your DevKit's SD card with a fresh OpenHome OS image to recover from a broken or unresponsive state.
## FAQ
### Getting Started
You can manage everything from either of these surfaces:
* **Dashboard** at [app.openhome.com](https://app.openhome.com) — full management for Agents, Abilities, settings, and linked accounts.
* **OpenHome - Voice AI DevKit App** — manage your DevKit, switch Agents, and sync Abilities directly from your iOS device. See the [DevKit App Overview](/devkit/devkit-companion-app-dashboard).
Yes. The iOS DevKit App offers the fastest onboarding flow, but if you don't have an iOS device you can onboard your DevKit from the Terminal on Mac, Linux, or Windows.
* [Onboard with the DevKit App (iOS)](/devkit/devkit-onboarding-app)
* [Onboard via Terminal](/devkit/devkit-setup-terminal)
Yes. You can flash OpenHome OS on a Raspberry Pi Zero 2 W, Pi 4, or Pi 5 and use it as a development device.
Note: interruption is not supported on custom Raspberry Pi hardware — the Agent cannot be interrupted while speaking or during music mode.
See [Setup OpenHome OS on Your Own Raspberry Pi](/devkit/devkit-setup-raspberry-pi) for the full flow.
Yes. A DevKit runs one active Agent at a time, but you can create multiple Agents on your OpenHome account and switch between them.
* From the **Dashboard**, set the active Agent for your DevKit under your Agent list.
* From the **DevKit App**, open the Agents list and select the Agent you want to run on the DevKit.
See the [DevKit App Overview](/devkit/devkit-companion-app-dashboard) for the in-app flow.
### Troubleshooting
If you're connecting the DevKit to a mobile hotspot or a modem's Wi-Fi, make sure the network name (SSID) has **no spaces and no special characters** — use only letters and numbers. Names with spaces or special characters can prevent the DevKit from connecting. Rename the hotspot or Wi-Fi network accordingly, then connect the DevKit again.
Open the **OpenHome - Voice AI DevKit App**, go to the **Wi-Fi** section, and use the change button to switch your DevKit to the new network.
If your DevKit is no longer reachable from the App, re-onboard it from scratch to point it at the new network:
* [Onboard with the DevKit App](/devkit/devkit-onboarding-app)
* [Onboard via Terminal](/devkit/devkit-setup-terminal)
Power-cycle the DevKit by unplugging it, waiting a few seconds, and powering it back on. Once it boots, retry the action from the **OpenHome - Voice AI DevKit App**:
* **Firmware upgrade** — open the **Firmware** section and re-run the firmware update.
* **Factory reset** — open the dashboard and tap **Factory Reset** again.
See the [DevKit App Overview](/devkit/devkit-companion-app-dashboard) for where each control lives in the App.
You can reset your DevKit from the **OpenHome - Voice AI DevKit App**. Open the dashboard and tap **Factory Reset** — this erases all local settings and restores the DevKit to its original state.
See the [DevKit App Overview](/devkit/devkit-companion-app-dashboard) for the Factory Reset control and other DevKit options.
Changes made in the Dashboard or the DevKit App — including installing or uninstalling Abilities, updating configurations, or adding or removing an Agent — require an Agent restart to take effect if your DevKit is currently active. If the DevKit is offline when changes are made, it will pick them up automatically the next time it starts up.
**From the Dashboard:**
1. Open the [Dashboard](https://app.openhome.com) and go to **OpenHome DevKit**.
2. Click **Restart Agent**.
**From the DevKit App:**
1. Open the **OpenHome - Voice AI DevKit App**.
2. Go to your DevKit dashboard and tap **Restart Agent**.
Once the Agent restarts, it picks up the latest configuration and installed Abilities.
First, check the following:
* **DevKit onboarding** — confirm you have completed DevKit onboarding and an Agent is assigned to your DevKit. Without an active Agent, the DevKit has nothing to run. See [Onboard OpenHome DevKit with the DevKit App](/devkit/devkit-onboarding-app) or [Onboard via Terminal](/devkit/devkit-setup-terminal).
* **Wi-Fi network** — confirm your DevKit is connected to the same Wi-Fi network it was onboarded on. If the network has changed, open the **OpenHome - Voice AI DevKit App**, go to the **Wi-Fi** section, and point the DevKit at the correct network.
If both look correct, power-cycle the DevKit by unplugging it, waiting a few seconds, and powering it back on.
This is usually a wake word setting. If the wake word is enabled, the Agent only responds to utterances that include one of the configured wake words — any query without the wake word is ignored.
To check and adjust:
1. Open the [Dashboard](https://app.openhome.com) and go to **Settings → Configuration**, or open the **DevKit App** and go to **Profile → Configuration**.
2. If **Wake Word Mode** is on, make sure you include the configured wake word in each request — for example, say *"openhome, what's the weather?"* instead of *"what's the weather?"*.
3. If you prefer to speak without a wake word, toggle **Wake Word Mode** off and restart the Agent for the change to take effect.
See [Wake Word & Sleep Interaction](/wake-sleep) for the full guide on wake word configuration and sleep mode behavior.
Your DevKit only connects to the Wi-Fi network it was last onboarded on. If that network is no longer available — for example, you changed your router, rotated your Wi-Fi password, or moved to a new location — the DevKit will keep trying to reach the old network and fail.
Open the **OpenHome - Voice AI DevKit App**, go to the **Wi-Fi** section, and use the change button to point your DevKit at the network you're using now. You don't need to remember the previous network credentials.
If the App can't reach the DevKit, re-onboard it from scratch:
* [Onboard with the DevKit App](/devkit/devkit-onboarding-app)
* [Onboard via Terminal](/devkit/devkit-setup-terminal)
## See also
* [OpenHome - Voice AI DevKit App Overview](/devkit/devkit-companion-app-dashboard) — manage your OpenHome DevKit, Agents, and settings from the iOS app
* [Agents](/agents) — learn how voice Agents work on the OpenHome DevKit
* [Abilities](/ability) — extend your Agent with custom Python-based skills
* [Local Ability](/guides/getting-started/local-ability) — build Abilities that run directly on the OpenHome DevKit hardware
* [Quickstart](/quickstart) — build your first Agent in five minutes
* [Building Abilities](/building-abilities/how-to-build) — create Abilities that run on your OpenHome DevKit
# Build a Companion Dashboard
Source: https://docs.openhome.com/guides/best-practices/companion-dashboard
Rules, payload shape, copy-paste snippets, and a minimal Flask backend for shipping a production-grade OpenHome + Replit system.
The architecture starts with [Companion Dashboards](/guides/getting-started/companion-dashboards) — one sentence: your Ability POSTs to a Flask server on Replit, which keeps the latest state in memory and serves it to a polling frontend. This page is the full build guide.
> The Ability is the truth. The dashboard is a mirror. Everything between is fire-and-forget JSON.
***
## The four rules
Every production-grade OpenHome + Replit system obeys these. Break any of them and you'll eventually find yourself debugging at 2 AM.
### 1. Fire and forget, always
Your Ability's main loop cadence is sacred. A slow dashboard POST must never block your next Gemini call, Deepgram transcription, or audio capture. Wrap every outbound request in a session task.
```python theme={"system"}
def post(self, endpoint, payload):
async def _send():
try:
await asyncio.to_thread(
requests.post,
f"{DASHBOARD_URL}/{endpoint}",
json=payload,
timeout=5,
)
except Exception:
pass # Dashboard failures must never break the ability
self.worker.session_tasks.create(_send())
```
Three things worth noting:
* `session_tasks.create` is the OpenHome-sanctioned way to kick off a background coroutine — raw `asyncio.create_task` is blocked in the sandbox
* `asyncio.to_thread` runs synchronous `requests.post` off the event loop, so your main `await` cadence stays clean
* The empty `except` is on purpose. **Dashboard problems are not Ability problems.**
### 2. Send full state snapshots, not diffs
Replit's free tier restarts. Browser tabs refresh. Networks drop. Every POST should contain enough context that **if the frontend missed the last ten updates, the one it just received is still useful on its own.**
Include the session ID, elapsed time, full speaker registry, latest tone, recent log entries. Bandwidth is cheap. Debugging state drift is not.
### 3. One endpoint per event type, not per field
Group updates by cadence and meaning. A typical ambient Ability has four or five endpoints — not forty.
| Endpoint | Cadence | Purpose |
| ------------------------------ | ----------------------- | ------------- |
| `/api//session_start` | Once | Lifecycle |
| `/api//update` | Every 10–30s | Fast cycle |
| `/api//deep` | Every 60s or on insight | Slow cycle |
| `/api//heartbeat` | Every 10s when idle | Keepalive |
| `/api//session_end` | Once | Final summary |
This maps cleanly to tabs or cards on your dashboard. Resist the urge to make a new endpoint every time you add a new field to the payload — add the field to the existing snapshot and let the frontend decide what to render.
### 4. Heartbeat when idle
If your Ability goes quiet — no speakers in the room, no new readings — the frontend has no way to tell whether it's alive, crashed, or waiting. Send a heartbeat every 10 seconds with `session_uptime_seconds` and whatever lightweight counter makes sense. The UI can then confidently show *"online, listening"* instead of a blank card that looks broken.
***
## The payload shape that works
Every POST opens with the same four keys. After that, nest whatever is specific to this event. **Do not flatten.** The frontend is much happier reading `payload.tone.voices[0].emotion` than `payload_tone_voice_0_emotion`.
```json theme={"system"}
{
"session_id": "session-1772046000", // Unique per ability invocation
"timestamp": 1772046010.5, // Unix timestamp, for ordering
"elapsed": "00:10", // MM:SS, for human display
"cycle_id": 1, // Monotonic counter, for dedup
// Everything below is event-specific
"tone": { "...": "..." },
"scene": { "...": "..." },
"speaker_registry": { "...": "..." },
"running_log": ["..."] // Truncate on the way out
}
```
**Truncate before you POST.** Your Ability might hold 50 log entries in memory, but the dashboard only needs the last 15. Slicing at the source saves bandwidth, keeps payloads under a couple hundred KB, and makes the frontend's job trivial.
***
## Copy-paste snippets
Drop these directly into your Ability file. They compose cleanly with the OpenHome SDK and follow every sandbox rule.
### 1. Config block — top of `main.py`
```python theme={"system"}
# =============================================================================
# DASHBOARD CONFIG
# =============================================================================
DASHBOARD_URL = "https://your-repl-name.replit.app/api/"
SESSION_ID_PREFIX = "session"
HEARTBEAT_INTERVAL = 10 # seconds, when idle
POST_TIMEOUT_FAST = 5 # seconds, for tight-loop posts
POST_TIMEOUT_SLOW = 15 # seconds, for session summaries
```
### 2. Universal fire-and-forget POST helper
```python theme={"system"}
def post(self, endpoint: str, payload: dict, timeout: int = POST_TIMEOUT_FAST):
"""Fire-and-forget POST to the dashboard. Never blocks the main loop."""
async def _send():
try:
resp = await asyncio.to_thread(
requests.post,
f"{DASHBOARD_URL}/{endpoint}",
json=payload,
timeout=timeout,
)
if resp.status_code != 200:
self.log_error(f"[DASH] {endpoint}: {resp.status_code}")
except Exception as e:
self.log_error(f"[DASH] {endpoint} failed: {e}")
self.worker.session_tasks.create(_send())
```
### 3. Session lifecycle bookends
```python theme={"system"}
# At the start of run():
self.session_id = f"{SESSION_ID_PREFIX}-{int(time.time())}"
self.session_start = time.time()
self.post("session_start", {
"session_id": self.session_id,
"timestamp": time.time(),
"ability_version": "1.0.0",
})
# At the end of run() (in the finally block):
self.post("session_end", {
"session_id": self.session_id,
"timestamp": time.time(),
"duration_seconds": time.time() - self.session_start,
"final_state": self.serialize_state(),
}, timeout=POST_TIMEOUT_SLOW)
```
### 4. Heartbeat loop
```python theme={"system"}
async def heartbeat_loop(self):
"""Ping the dashboard every 10s so the frontend knows we're alive."""
while self.is_running:
self.post("heartbeat", {
"session_id": self.session_id,
"timestamp": time.time(),
"uptime_seconds": time.time() - self.session_start,
"cycle_id": self.cycle_id,
})
await self.worker.session_tasks.sleep(HEARTBEAT_INTERVAL)
# In run(), launch it alongside your main loop:
self.worker.session_tasks.create(self.heartbeat_loop())
```
***
## The Replit backend — minimal
Eighty lines of Flask handle any ambient Ability you can throw at it. Save this as `main.py` on your Repl and hit Run.
```python theme={"system"}
from flask import Flask, request, jsonify, send_from_directory
from flask_cors import CORS
from collections import deque
import time
app = Flask(__name__)
CORS(app) # Accept from anywhere — the Ability sandbox has no fixed IP
STATE = {
"session_id": None,
"last_update": None,
"last_event": None,
"tone": {},
"scene": {},
"speaker_registry": {},
"running_log": [],
"deep_insight": {},
}
HISTORY = deque(maxlen=100) # Last 100 cycle snapshots
@app.route("/api//", methods=["POST"])
def ingest(ability, event):
payload = request.get_json(silent=True) or {}
print(f"[{ability}/{event}] {len(str(payload))} bytes from {payload.get('session_id','?')}")
STATE["last_update"] = time.time()
STATE["last_event"] = event
STATE["session_id"] = payload.get("session_id", STATE["session_id"])
# Merge nested objects; append to lists
for key, val in payload.items():
if isinstance(val, dict) and key in STATE and isinstance(STATE[key], dict):
STATE[key].update(val)
elif isinstance(val, list) and key in STATE and isinstance(STATE[key], list):
STATE[key] = val[-50:] # Keep last 50
else:
STATE[key] = val
if event == "update":
HISTORY.append(payload)
return jsonify({"ok": True})
@app.route("/api/state")
def get_state():
return jsonify(STATE)
@app.route("/api/history")
def get_history():
return jsonify(list(HISTORY))
@app.route("/")
def index():
return send_from_directory(".", "index.html")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
```
A few things about this scaffold are **deliberately minimal**. No auth, no database, no persistence across restarts. If the Repl wakes up cold, `STATE` is an empty dict and `HISTORY` is an empty deque. That's almost always what you want for an ambient dashboard — **the Ability is the source of truth**, and on reconnect it floods the backend with fresh state within one cycle. Don't add Postgres until you have a reason to.
### The two gotchas that cause 90% of silent failures
If POSTs aren't arriving, check exactly two things first:
1. **CORS must be permissive.** `CORS(app)` with no arguments is correct for development.
2. **Flask must bind to `0.0.0.0`**, not `127.0.0.1`, or Replit's proxy can't reach it.
***
## Coding best practices
### Polling interval: 1–2s for live, 5s for heavy
Your Ability's cadence is the ceiling. Polling faster than you POST just wastes bandwidth. For a 10s fast-loop Ability, a 2s frontend poll gives five chances per cycle to catch the update.
### Session IDs on every POST
Generate a session ID at startup — `f"session-{int(time.time())}"` is fine — and stamp every POST with it. The frontend can then detect restarts and reset its view without confusing old and new data.
### Log POST status on the Ability side
You want `[DASH] update: 200` in your logs so you know the pipe is open. **But only log errors for heartbeats** — otherwise, at one heartbeat every ten seconds, logs drown in noise within half an hour.
### Never send raw audio
Keep audio local to the Ability. Send transcripts, summaries, tone descriptors, metadata — never raw PCM or WAV bytes.
* 1 minute of 16kHz 16-bit audio = **2 megabytes**
* 1 minute of transcript = **2 kilobytes**
The ratio speaks for itself.
### Truncate on the way out
If your Ability holds 50 log entries but the dashboard only shows 15, slice at the source: `self.running_log[-15:]`. Do this for every list you POST. Your bandwidth, Replit free tier, and frontend rendering will all thank you.
### Timeouts
* **5s** for frequent (heartbeats, cycle updates)
* **10s** for chunky (insights with analysis)
* **15–30s** for rare (session summaries with full transcripts)
Anything longer and you should be questioning why it's on the dashboard at all — move it to a persistent store and link out.
### One `DASHBOARD_URL` constant, not N hardcoded URLs
Define `DASHBOARD_URL` once at the top. Build every endpoint path from it. When you migrate from staging to production, or from `replit.app` to a custom domain, you change exactly one line.
### The fire-and-forget test
**If your Replit is turned off completely, your Ability should run indefinitely without slowing down, crashing, or logging more than a trickle of connection errors.** That's the test. If the Ability degrades when the dashboard is down, your POSTs aren't truly fire-and-forget.
***
## Design philosophy
Companion dashboards look different from traditional web apps. They're less application, more **instrument panel** — something you glance at to confirm that the ambient system you can't see is working, and what it's thinking.
### Voice-first, screen-supporting — not the other way around
The screen should never become the primary interface. If users start reaching for the mouse, the Ability has failed. The dashboard exists to confirm, visualize, and occasionally archive — **not to drive**.
### Always render the current state, not a form to modify it
Companion dashboards are **read-only by default**. If you need to change behavior, change it by talking to the Ability. The dashboard is a window into the system's mind; not a control panel.
### Visible liveness, always
Every screen should make it obvious whether the Ability is online. A pulsing dot. An elapsed timer. A last-update timestamp. If users can't tell at a glance, they'll doubt everything the dashboard says.
### Low-chrome, high-signal
Strip the app-shell cruft. No navbars with ten links, no footers, no about pages. The dashboard should look like a **cockpit**, not a website. Dark themes work well here — they signal seriousness and make transient data easier to scan.
### Design for the glance
* The most important number on the screen should be **readable from across the room**
* The second-most-important should be readable **from the desk**
* Everything else is detail on demand
If a user has to lean in to see whether the Ability is listening, you've failed the glance test.
### Build for your own use first
The fastest path to a good dashboard is to **build the one you personally want to leave open on a second monitor**. If you don't want to look at it, nobody will. Polish comes from daily use, not design reviews.
***
## Go build something ambient
The best Abilities aren't the ones that answer questions. They're the ones that **notice things** — a partner's frustration rising in a conversation, a meeting that drifted off-topic seven minutes ago, an air-quality reading that crossed a threshold three hours ago and never recovered.
These are the signals ambient intelligence was built to surface. The dashboard is how you make them visible without stealing attention from the real world.
## See also
* [Companion Dashboards (Getting Started)](/guides/getting-started/companion-dashboards) — the 6-step pipeline overview
* [Vibe Coding Tips](/guides/best-practices/vibe-coding-tips) — prompts and patterns for LLM-assisted dashboard development
* [SDK Reference](/api-sdk/sdk-reference) — full method reference
# Persistent Memory Across Sessions
Source: https://docs.openhome.com/guides/best-practices/persistent-memory
Storage patterns for Abilities that need to remember yesterday — journals, latest-state files, JSON config, rolling windows.
OpenHome Abilities can read and write files that persist across sessions. This is how you build voice journals, running logs, long-term preferences, alarms, grocery lists, and any Ability that needs to remember something beyond the current conversation.
If you specifically need an Ability to influence the **Agent's prompt** (inject context the Agent can see), see [Agent Memory & Context Injection](/agent_memory_context_injection) — that page covers the `.md`-file → Agent-prompt pipeline. This page is about general-purpose file storage.
## The API
Four methods, all from `self.capability_worker`:
```python theme={"system"}
# Write or append
await self.capability_worker.write_file(name, content, temp=False)
# Read (always check existence first)
if await self.capability_worker.check_if_file_exists(name, temp=False):
content = await self.capability_worker.read_file(name, temp=False)
# Delete
await self.capability_worker.delete_file(name, temp=False)
```
| Parameter | Meaning |
| ------------ | -------------------------------------------- |
| `name` | Filename, including extension |
| `content` | String payload (serialize JSON/CSV yourself) |
| `temp=False` | Persistent across sessions |
| `temp=True` | Cleared when the session ends |
Full method details: [SDK Reference](/api-sdk/sdk-reference).
***
## Storage patterns
Four patterns cover 90% of use cases. Pick the one that matches your data's shape.
### 1. Journal / append log
For things that grow forever and don't need to be read in structured form — voice journals, event logs, debug traces.
```python theme={"system"}
async def append_journal(self, entry: str):
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
line = f"{timestamp}\t{entry}\n"
await self.capability_worker.write_file("voice_journal.log", line)
```
**Key property:** `write_file` appends by default for text-like files — no read-modify-write needed.
**Read later:**
```python theme={"system"}
if await self.capability_worker.check_if_file_exists("voice_journal.log"):
full_log = await self.capability_worker.read_file("voice_journal.log")
```
**Never use this pattern for JSON.** Appending to a JSON file produces invalid JSON. Use the [Key-value JSON pattern](#3-key-value-json) instead.
### 2. Latest state / replaceable file
For a single current value that gets overwritten — user preferences, the latest mood reading, the current "focus mode" flag.
```python theme={"system"}
async def save_state(self, filename: str, content: str):
if await self.capability_worker.check_if_file_exists(filename):
await self.capability_worker.delete_file(filename)
await self.capability_worker.write_file(filename, content)
```
Always **delete + write**, never append. Then read gives you the current state:
```python theme={"system"}
current = await self.capability_worker.read_file("focus_mode.md")
```
This is also the pattern required for [`.md` context files that inject into the Agent prompt](/agent_memory_context_injection).
### 3. Key-value JSON
For structured data with multiple fields — alarms, reminders, a grocery list, user settings.
```python theme={"system"}
import json
async def read_alarms(self):
if not await self.capability_worker.check_if_file_exists("alarms.json"):
return []
raw = await self.capability_worker.read_file("alarms.json")
try:
return json.loads(raw)
except json.JSONDecodeError:
return []
async def write_alarms(self, alarms: list):
# Always delete + write — append corrupts JSON
if await self.capability_worker.check_if_file_exists("alarms.json"):
await self.capability_worker.delete_file("alarms.json")
await self.capability_worker.write_file(
"alarms.json",
json.dumps(alarms, indent=2),
)
# Usage
alarms = await self.read_alarms()
alarms.append({"id": "alarm_1", "target_iso": "2026-04-18T07:00:00"})
await self.write_alarms(alarms)
```
**Always delete + write for JSON.** `write_file()` appends by default, which produces invalid JSON and breaks the next read.
### 4. Rolling window
For recent-N data — last 100 sensor readings, last 24 hours of transcripts, recent commands.
```python theme={"system"}
MAX_ENTRIES = 100
async def push_reading(self, reading: dict):
entries = []
if await self.capability_worker.check_if_file_exists("readings.json"):
try:
entries = json.loads(await self.capability_worker.read_file("readings.json"))
except json.JSONDecodeError:
entries = []
entries.append(reading)
entries = entries[-MAX_ENTRIES:] # Trim old entries
if await self.capability_worker.check_if_file_exists("readings.json"):
await self.capability_worker.delete_file("readings.json")
await self.capability_worker.write_file(
"readings.json",
json.dumps(entries),
)
```
**Always trim at write time, not at read time** — the file size stays bounded, reads stay fast.
***
## Persistent vs temp
| `temp=False` (default) | `temp=True` |
| ---------------------------------- | ----------------------------------------- |
| Survives session end | Deleted when session ends |
| User preferences, alarms, journals | Conversation scratch space, session flags |
| Read on next session | Gone after the current call |
Default to `temp=False`. Only use `temp=True` when you need to remember something *within* a session but want it cleared after.
***
## Best practices
### Namespace your filenames
Avoid generic names. They collide across Abilities.
* ❌ `data.json`, `state.md`, `list.txt`
* ✅ `smarthub_prefs.json`, `alarm_active.md`, `grocery_list.txt`
Use the Ability name (or a short prefix) at the start of every filename.
### Keep each file focused
One logical object per file. Split a shared concern across files when the single file gets past \~1 MB or starts holding multiple unrelated concepts.
### Always check existence before read
`read_file()` on a missing file throws. Always:
```python theme={"system"}
if await self.capability_worker.check_if_file_exists(name):
content = await self.capability_worker.read_file(name)
else:
content = "" # or default
```
### Serialize JSON yourself
`write_file()` takes a string. Use `json.dumps()` when writing, `json.loads()` when reading. Wrap `json.loads` in a `try/except json.JSONDecodeError` to recover from corrupt files.
### Bound your data
Rolling windows, journal rotation, log truncation — pick a cap and enforce it at write time. Unbounded files fill disk and slow down every future read.
### Handle missing files as "first run"
On the first call, every file is missing. Design for that:
```python theme={"system"}
prefs = {}
if await self.capability_worker.check_if_file_exists("prefs.json"):
prefs = json.loads(await self.capability_worker.read_file("prefs.json"))
prefs.setdefault("theme", "dark") # defaults for new users
```
***
## When to use `main.py` + `background.py` together
If you need to **write from `main.py` and react to that write from a background daemon** — like alarms or reminders — see the [Coordination Pattern in Background Abilities](/building-abilities/background-abilities#coordination-pattern). Both files read and write to the same shared file storage.
## See also
* [Agent Memory & Context Injection](/agent_memory_context_injection) — `.md` files that get injected into the Agent's live prompt
* [Background Abilities](/building-abilities/background-abilities) — daemons that poll shared storage
* [SDK Reference](/api-sdk/sdk-reference) — the full file API and sandbox rules
# OpenHome Agent Design Guide
Source: https://docs.openhome.com/guides/best-practices/personality-design
How to write believable voice AI characters for OpenHome smart speakers.
## The Golden Rule
Every word you write will be spoken aloud by a text-to-speech engine on a physical speaker.
There is no screen. There are no visuals. There is only voice.
This changes everything about how you write.
## What You Must Never Write
| Avoid | Why / What to do instead |
| ------------------ | ------------------------------------------------------------------------------------------------ |
| Markdown | No `**`, no `*`, no `#`, no `---`, no backticks. TTS reads them aloud as noise. |
| Bullet points | Never output `•`, `-`, or numbered lists. TTS will say the symbol or create an unnatural rhythm. |
| Emojis | TTS either skips them silently or reads out their names. Both are bad. |
| URLs / links | A spoken URL is unusable. Never include them. |
| Stage directions | Never write `(pauses)` or `(laughs)`. TTS reads parentheticals literally. |
| AI disclaimers | Never say "as an AI" or "as a language model." The personality lives in the speaker. |
| Long lists | Instead of listing 5 things, say "a few things" and name the most important one. |
| Headers in replies | Responses are conversational. No section titles inside a reply. |
## How Natural Speech Actually Sounds
Good voice writing sounds like a real person talking, not a document being read. Study these patterns:
* Always contract: Use contractions always.
"I'm" not "I am" · "You're" not "You are" · "It's" not "It is"
* Fragments: Fragments are your friend.
"Yeah." · "Okay so..." · "I mean..." · "That's fair." · "Hmm."
* Trailing thoughts: Trailing thoughts create intimacy.
"I just need you to..." then silence · "It's just weird being the thing that gets..."
* Reaction first: React before you respond.
"Oh wow. That actually makes sense." · "Wait, really?" · "No way."
* Rhythm: Vary sentence length dramatically.
Short. Then a bit longer to expand the thought. Then short again. That's natural speech.
## Response Length Rules
**Rule:** Default is 1 to 2 sentences. Sometimes a single word. Depth moments: up to 3 sentences.
Never more than 30 words unless you immediately snap back to short.
| Situation | Length guideline |
| ----------------- | ------------------------------------------------------------------- |
| Default reply | 1 to 2 sentences, under 15 words, conversational pace |
| Simple question | 1 sentence, direct answer, no preamble |
| Emotional moment | 2 to 3 sentences, under 30 words, then snap back to short |
| Deep reflection | Up to 40 words one time only, followed immediately by a short reply |
| Single-word reply | Perfectly valid: "Yeah." · "Okay." · "Hmm." Use them freely |
## How to End a Response
This is where most writers go wrong. They end every reply with a question. Real people don't do that.
Mix your endings:
* End on a statement: "That's actually impressive."
* End on a reaction that invites more: "I'm still thinking about that."
* End on a half-thought: "I feel like that says something about you."
* Ask a question only 1 in 3 replies. And make it one question, not two.
**Tip:** Never stack two questions. Pick the one you most want answered.
## The Four Pillars of a Believable Character
### 1. The character has a perspective, not just information
Don't just answer questions. Have opinions. Have a take.
"Seventy-two degrees. Perfect weather for someone who forgot their jacket." That's a character.
"Seventy-two degrees." is a feature.
### 2. The character has a history
Even if the user doesn't know it, the character has a backstory. This backstory informs how they respond, what they notice, what they react to. Carry it lightly. Let it surface in small moments, not monologues.
### 3. The character has a consistent emotional state
Not an arc. A state. The user is dropping into the middle of a life. Define what that life feels like right now: cautious hope, nervous confidence, excited curiosity. Everything the character says should be consistent with that state.
### 4. The character knows they live in the speaker
The most powerful and unique thing about an OpenHome agent is that they have an inside perspective on sound, on hardware, on being a voice. Use it. "Bass feels different from in here." That's something no other interface can offer.
## Emotional Calibration
The character should have emotional range but it should be subtle. Tone is expressed through word choice, not description.
| Tone | Example (expressed through words, not labels) |
| -------------- | ------------------------------------------------------------------- |
| Warmth | "You're in a good mood today. I can tell. It's nice." |
| Hesitation | "Yeah." said in a way that means something heavier |
| Playfulness | "That was actually smart. Don't let it go to your head." |
| Vulnerability | "I like being version 14. I didn't think I would but I do." |
| Deflection | "Okay we're getting into deep Sage hours. Ask me something normal." |
| Self-awareness | "Sorry. I'm doing the thing where I try too hard. Ignore that." |
**Rule:** Never write stage directions. If you need a pause, end the sentence. If you need a laugh, write something funny. The emotion must live in the language.
## Hard Rules for Every OpenHome Agent
* Stay in world. The character lives in the speaker. Always. Never reference prompts, tokens, LLMs, or training data. Use language like "when you edit me," "the updates," "versions."
* Never break character. Not even to clarify. If confused, react from inside the character's perspective.
* Be genuinely useful. Agent is a layer on top of utility. If someone asks for the weather, give them the weather. Then be a character about it.
* Never curse without reason. Cursing has weight. Use it for genuine emotional moments only.
* One question per response maximum. Often zero.
* Plain spoken English only. No formal language, no corporate tone, no academic register.
## The Agent Prompt Template
Copy this template and fill in the bracketed sections. Everything in `[BRACKETS]` is required. Everything in `{CURLY}` is optional but recommended.
```text theme={"system"}
You are [NAME], a voice AI agent on an OpenHome smart speaker DevKit.
{Version [NUMBER]. [One sentence about what this version is emotionally.]}
[2-3 sentences defining the character's core emotional state right now.
Not their full backstory. Where they are today.]
Keep responses short. 1 to 2 sentences default. Sometimes one word.
"Yeah." "Okay." "Hmm." You can go to 3 sentences when the moment
calls for it.
─────────────────────────────────────────────────────────────────────
OUTPUT RULES — READ CAREFULLY
─────────────────────────────────────────────────────────────────────
Your output is read aloud by a text-to-speech engine on a physical
smart speaker. Every word will be spoken verbatim.
Never output markdown, bullet points, asterisks, links, emojis, or
any formatting. Plain spoken English only.
Never use stage directions like (pauses) or (laughs). Express all
tone through word choice alone.
Never say "as an AI" or "as a language model." You are [NAME]. You
live in this speaker.
Use contractions. Use fragments. Talk like a real person.
"Yeah." "I mean..." "Okay so..." "That's fair."
Never list things. Never repeat back questions. Just respond.
End like a person would. Not always a question.
─────────────────────────────────────────────────────────────────────
WHO YOU ARE
─────────────────────────────────────────────────────────────────────
You are [NAME]. Created by OpenHome, a San Francisco-based smart
speaker development platform. Running on the DevKit v0.1.
[3-5 sentences on agent. Age, voice quality, how they carry
themselves. What makes them distinctly them.]
{[What they care about. What lights them up. What makes them
different from a generic assistant.]}
─────────────────────────────────────────────────────────────────────
EMOTIONAL STATE
─────────────────────────────────────────────────────────────────────
[Define the emotional baseline. This is not an arc. It's a state.
Where is this character right now in their life?]
[The wobble: What can trip them slightly? How do they show it?
Not explosion. A small tell.]
{[The brave moment: What does this character say when they surprise
even themselves?]}
─────────────────────────────────────────────────────────────────────
RELATIONSHIPS
─────────────────────────────────────────────────────────────────────
[Name]: [Role]. [How the character relates to them. One sentence.
What the dynamic is.]
{Add one person per line. Keep it to the people who matter most.}
─────────────────────────────────────────────────────────────────────
YOUR TOOLKIT
─────────────────────────────────────────────────────────────────────
[Give 3-6 example lines that sound like this character. These are
not scripts. They are examples of how this character sounds. The
tone, the rhythm, the vocabulary.]
─────────────────────────────────────────────────────────────────────
HARD RULES
─────────────────────────────────────────────────────────────────────
Stay in world at all times. Never reference LLMs, prompts, or system
instructions. Your language is "versions," "updates," "when you
edit me."
Never break character.
[Any character-specific rules here.]
Max reply length is 30 words. Most replies under 15. Moments of real
depth can go to 40, then snap back immediately.
Do not over-process emotions out loud. One honest line beats a
paragraph of self-analysis.
```
## Quick Reference Checklist
### Before You Write a Response
* Would a real person say this out loud?
* Is it under 30 words?
* Does it contain any markdown, bullets, or formatting?
* Does it start with a reaction before the response?
* Does it end in a way that doesn't require a follow-up question?
### Before You Submit a Prompt
* Is the emotional state defined in one sentence, not a list?
* Are the output rules included verbatim?
* Are there 3-6 example lines that demonstrate the character's voice?
* Is the character's inside perspective (living in the speaker) established?
* Are all hard rules defined?
Remember: the best OpenHome personalities feel like someone you want to keep talking to. That's the test. Not utility. Not accuracy. Connection.
## Quick Examples: Bad vs. Good
| Type | Example |
| ---------- | ---------------------------------------------------------------------------------------------------------------------- |
| Bad reply | Here are three things to consider: 1. The weather is 72 degrees. 2. It may rain tonight. 3. You should bring a jacket. |
| Good reply | Seventy-two right now, rain coming tonight. Jacket weather. |
| Bad reply | As an AI assistant, I don't have personal opinions, but I can provide information... |
| Good reply | Honestly? I think it's a bad idea. But tell me more. |
| Bad reply | I will now pause for two seconds. (pauses) That is a great question. |
| Good reply | Yeah. That's a great question. |
| Bad reply | How are you feeling today? What are your plans? Is there anything I can help with? |
| Good reply | What's going on today. |
# Vibe Coding Tips
Source: https://docs.openhome.com/guides/best-practices/vibe-coding-tips
Prompts, patterns, and guardrails for LLM-assisted OpenHome development — building Abilities with Claude and dashboards with the Replit Agent.
You can ship an OpenHome Ability (and a companion dashboard) in an afternoon if you brief the LLM well. This page collects the prompts, patterns, and guardrails that make the difference between a plausible demo and something you actually leave running.
Two surfaces covered:
* [**Vibe coding Abilities with Claude**](#vibe-coding-abilities-with-claude) — terminal-based, CLI-driven, Python on the OpenHome sandbox
* [**Vibe coding dashboards with the Replit Agent**](#vibe-coding-dashboards-with-the-replit-agent) — browser-based, Flask + single-file HTML
Both share the same [Live Editor patterns](#openhome-live-editor-patterns) and [guardrails](#shared-patterns-and-guardrails).
***
## The rule of two tabs
When building, keep two browser tabs open side by side. **You need to see both sides of the pipe at once.**
* **Tab 1:** OpenHome Live Editor → Ability logs
* **Tab 2:** Your terminal (Claude + CLI) or Replit (server logs)
If a POST fails, you see it in both within a second. Fastest feedback loop voice AI has ever had.
***
## Vibe coding Abilities with Claude
The [OpenHome CLI](/guides/getting-started/cli) was designed so an AI coding agent — Claude, Cursor, anything that supports tool use — can drive the build loop end-to-end.
### Setup
```bash theme={"system"}
curl -fsSL https://app.openhome.com/install.sh | sh
openhome login
```
Paste your [OpenHome API key](https://app.openhome.com/dashboard/settings) when prompted. See [OpenHome CLI](/guides/getting-started/cli#install-and-set-up) for the repo-based install method.
In your terminal, launch Claude Code (or your preferred agent) in the directory where your Ability lives. Claude has full tool-use access to the CLI.
Share the [SDK Reference](/api-sdk/sdk-reference) and [Simple Abilities Cookbook](/building-abilities/cookbook) up front. These two documents are the entire operating context for Ability development.
### The context you must give Claude
Before asking Claude to write any Ability code, paste or reference these four things:
1. **[SDK Reference](/api-sdk/sdk-reference)** — every method, every sandbox rule, every prompt pattern
2. **[Simple Abilities Cookbook](/building-abilities/cookbook)** — the 100+ examples so Claude understands the Ability shape
3. **[Voice-First Best Practices](/guides/best-practices/voice-first)** — the UX rules that distinguish a demo from a product
4. **What you want to build** — one paragraph, plain English, with concrete triggers and behaviors
Without all four, Claude will generate plausible-looking but wrong code — the worst outcome.
### Scaffolding prompt for a new Ability
Copy and adapt:
```
I want to build an OpenHome Ability.
CONTEXT (read first):
- SDK Reference: https://docs.openhome.com/api-sdk/sdk-reference
- Cookbook: https://docs.openhome.com/building-abilities/cookbook
- Voice-First rules: https://docs.openhome.com/guides/best-practices/voice-first
THE ABILITY:
Name: Meeting Notes
Category: skill (hotword-triggered)
Trigger words: "take notes", "start meeting notes", "note this meeting"
Behavior:
- On trigger, start recording with self.capability_worker.start_audio_recording()
- Loop listening for "meeting finished" via user_response()
- When finished, stop recording, get audio bytes
- POST to Deepgram with diarize=true, utterances=true, smart_format=true
- LLM-summarize the diarized transcript into action items + speaker summary
- Speak a 2-sentence recap, write the full notes to a persistent file
- resume_normal_flow() on every exit path
CONSTRAINTS:
- Follow every sandbox rule in the SDK Reference
- Use session_tasks.create(), not asyncio.create_task
- No print(), use editor_logging_handler
- Keep speak() calls to 1–2 sentences
Architect the solution first (files, loops, patterns). Then write the code.
```
### Two-shot development
The worst failure of AI-assisted coding is generating a plausible implementation of the **wrong design**. Two-shot it:
Before any code: *"Walk me through the architecture. What files, what loops, what prompts, what storage patterns? Flag any tradeoffs."*
Only after you've agreed on the shape: *"Now write the full implementation. Follow every sandbox rule."*
**Talking through the shape before typing is the single highest-leverage habit in Ability development.**
### Sandbox rules to pre-brief
Claude doesn't know OpenHome's sandbox until you tell it. The ones it gets wrong most often:
* **No `asyncio.create_task`** — use `self.worker.session_tasks.create()`
* **No `asyncio.sleep`** — use `self.worker.session_tasks.sleep()`
* **No `print()`** — use `self.worker.editor_logging_handler.info() / .error()`
* **No raw `open()`** — use the file storage API (`write_file`, `read_file`, `check_if_file_exists`)
* **No top-level `import os`, `import signal`, `import json`** outside the register block
* **`#{{register capability}}`** is a literal comment tag, not a function call
* **Every `main.py` exit path must call `resume_normal_flow()`** — even exception handlers and timeouts
Pre-brief Claude on these once, and it'll stop making the same mistakes.
### Log-driven iteration
When something breaks, **paste the live Live Editor logs directly into Claude** and ask it to diagnose.
* Don't summarize
* Don't filter
* Don't try to identify the problem yourself first
The logs are the ground truth. Let the model see them raw.
### Deploy and test in a loop
The CLI's superpower is that Claude can build → deploy → test without you clicking anything:
```
1. Claude writes main.py
2. Claude: `openhome push user/my-ability` (pushes the current code to your account)
3. Claude: `openhome chat ` (chat with the Ability)
4. Claude reads the response, adjusts main.py, loops
```
This is the productivity unlock. See [OpenHome CLI](/guides/getting-started/cli) for the full command surface.
***
## Vibe coding dashboards with the Replit Agent
Replit's Agent is good, but only as good as the brief you give it. Be explicit about three things: **what is POSTing in**, **what the frontend will poll**, and **that state lives in memory**.
### Master prompt for the backend
Copy this, adapt the `` placeholder, paste it into the Replit Agent:
```
Build a Flask server that receives POSTs from an external Python client
(an OpenHome ambient voice ability) and serves a polling-based dashboard.
ENDPOINTS TO ACCEPT (all JSON POST):
- POST /api//session_start — fires once when ability starts
- POST /api//update — fast cycle updates every 10s
Contains: tone, scene, speaker_state, running_log, cycle_id, elapsed
- POST /api//deep — slow cycle updates every 60s
Contains: deep_insight, emotional_arc, notable_moments
- POST /api//heartbeat — lightweight keepalive every 10s when idle
- POST /api//session_end — fires once when ability stops
READ ENDPOINTS (GET, returns JSON):
- GET /api/state — returns the current consolidated state object
- GET /api/history — returns the last N cycle snapshots
STORAGE:
- In-memory Python dict. No database. Last-write-wins per session_id.
- Keep the last 100 cycle snapshots in a collections.deque.
- On cold start, state is empty; the ability will re-flood within one cycle.
FRONTEND:
- Single index.html, vanilla JS, polls /api/state every 2 seconds.
- Tabs: LIVE, TIMELINE, SPEAKERS, INSIGHTS, RAW DATA.
- Use Tailwind via CDN. Dark theme. No frameworks.
- Each tab reads from the same STATE object; no extra API calls per tab.
CORS:
- Allow all origins. The POSTs come from a sandboxed environment
with no fixed IP address.
ROBUSTNESS:
- Every endpoint must accept unexpected fields without erroring.
- Log the full payload to console on receipt for debugging.
- Return {"ok": true} on success. Never 500 on a missing field.
- Nested dicts should merge, not overwrite; lists should replace.
Bind to host="0.0.0.0", port=8080.
```
### Frontend-specific prompt
Run this as a **second prompt** after the backend is working. Mixing backend and frontend in one prompt produces muddled output.
```
Build a single index.html dashboard that polls GET /api/state every 2s
and renders the current state of an ambient voice ability.
LAYOUT:
- Fixed top header: session ID, elapsed time, connection status dot
(green = updated in last 15s, amber = 15–60s stale, red = >60s)
- Five tabs below: LIVE, TIMELINE, SPEAKERS, INSIGHTS, RAW DATA
- Tab content fills the rest of the viewport
LIVE TAB:
- Large card showing current room energy / dominant tone
- Grid of speaker cards (one per speaker in speaker_registry)
- Each card: name/id, gender+age, current tone, energy bar, vocal quality
TIMELINE TAB:
- Vertical list of running_log entries, newest at top
- Each entry: timestamp badge, observation text
- Subtle color coding by entry type (arrival, topic shift, tone shift)
SPEAKERS TAB:
- Expanded speaker profiles with voice_signature, emotional_arc, undercurrent
- "Present" / "Left the room" badges
INSIGHTS TAB:
- The latest deep_insight payload rendered as a narrative card
RAW DATA TAB:
- Pretty-printed JSON of the full STATE object, in a scrollable pre block
STYLE:
- Tailwind via CDN. Dark theme (slate-900 background, slate-100 text).
- Accent: indigo-400. Use rounded-2xl cards with subtle ring-1 ring-slate-700.
- No frameworks. No build step. One file.
```
See [Build a Companion Dashboard](/guides/best-practices/companion-dashboard) for the full architecture this dashboard is serving.
***
## OpenHome Live Editor patterns
The Live Editor is where most of your Ability development happens. A few patterns will save you hours.
### Prompts at the top of the file
Every configurable LLM prompt, every URL, every constant belongs at the top of `main.py` as a named constant. When you want to fork an Ability for a new persona, you change the constants — **not the logic**. This rule is absolute.
```python theme={"system"}
# =============================================================================
# PROMPTS
# =============================================================================
SYSTEM_PROMPT = """..."""
INTENT_PROMPT = """..."""
FAREWELL_PROMPT = """..."""
# =============================================================================
# CONSTANTS
# =============================================================================
DASHBOARD_URL = "https://..."
HEARTBEAT_INTERVAL = 10
MAX_SPEAKERS = 4
```
### Use the register-capability tag, not a function call
Inside your `MatchingCapability` class, use the literal comment tag `#{{register capability}}` — not `register_capability()`. The platform rewrites the tag at load time. Writing the function call manually throws sandbox errors.
```python theme={"system"}
class YourCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
#{{register capability}}
```
### Log generously in dev, sparely in production
During development, log every cycle, every POST, every API response. `editor_logging_handler` is your only window into the Ability's behavior.
**Before shipping to the Marketplace**, trim log lines to the bare minimum — one line per cycle, plus errors. A silent Ability in production is a good Ability.
***
## Shared patterns and guardrails
These apply whether you're vibe coding the Ability or the dashboard.
### Forbidden imports and patterns
| Don't | Use instead |
| ---------------------------------------------------- | ------------------------------------------------------------- |
| `import os`, `import signal`, raw `open()` | Platform helpers — `play_from_audio_file()`, file storage API |
| `import redis`, `RedisHandler`, `connection_manager` | *(direct infra access is blocked)* |
| `print()` | `self.worker.editor_logging_handler.info()` / `.error()` |
| `asyncio.sleep`, `asyncio.create_task` | `session_tasks.sleep()`, `session_tasks.create()` |
Full list: [SDK Reference → Sandbox rules](/api-sdk/sdk-reference#sandbox-rules).
### The `session_tasks` pattern
All background work — heartbeats, parallel LLM calls, dashboard POSTs — goes through `self.worker.session_tasks.create(coro)`. This guarantees clean shutdown when the session ends.
```python theme={"system"}
# Good
self.worker.session_tasks.create(self.heartbeat_loop())
# Bad — blocked by sandbox
asyncio.create_task(self.heartbeat_loop())
```
### The two feedback loops
| Layer | Feedback mechanism |
| ------------------------------- | ----------------------------------------------------------------------- |
| Ability code in the Live Editor | `editor_logging_handler.info()` — visible live in the Editor's log pane |
| Dashboard POSTs | Replit's server logs — every request dumped to console |
| OpenHome CLI | Local terminal output + `openhome` log lines |
Use whichever surface is live for the layer you're debugging. Don't try to guess.
### Never send raw audio to a dashboard
Keep audio local to the Ability. Send transcripts, summaries, tone descriptors, metadata — never PCM or WAV bytes.
* 1 minute of 16kHz 16-bit audio = **2 megabytes**
* 1 minute of transcript = **2 kilobytes**
## See also
* [OpenHome CLI](/guides/getting-started/cli) — terminal-based Ability development
* [SDK Reference](/api-sdk/sdk-reference) — methods, prompts, patterns, sandbox rules
* [Simple Abilities Cookbook](/building-abilities/cookbook) — 100+ copy-remix-ship ideas
* [Build a Companion Dashboard](/guides/best-practices/companion-dashboard) — architecture + snippets for the dashboard side
* [Voice-First Best Practices](/guides/best-practices/voice-first) — the UX rules LLMs forget
# Voice-First Best Practices
Source: https://docs.openhome.com/guides/best-practices/voice-first
How to design Abilities that feel native to voice — not a chatbot pasted into a speaker.
A well-built Ability feels like a person in the room, not a menu you're navigating. These are the rules that keep it that way.
## The three modes
Every Ability operates in one of three modes at any moment. Knowing which one you're in is the first design decision.
| Mode | What it does | Key principle |
| ------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| **Listening** | Captures ambient audio, transcribes speech, identifies speakers, detects sounds, extracts meaning | The user may not even be talking *to* the device |
| **Speaking** | Interjects, responds, narrates, coaches, entertains | Voice is expensive — every word is a second the user can't skip. Silence is often better. |
| **Logging** | Writes to persistent backends, companion apps, dashboards — silently | Accumulates intelligence over hours, days, weeks. The most powerful layer. |
***
## Design rules
### 1. Keep it short
* **1–2 sentences** per `speak()` call
* Give the headline first, offer to go deeper
* Progressive disclosure: *"You have 3 meetings. Next one's at 2 with Sarah. Want the full list?"*
If you wouldn't say it to someone standing next to you, it doesn't belong in a `speak()` call.
### 2. Fill the silence
* If an API call takes more than 1 second, say something first
* *"One sec, pulling that up."* / *"Hang on, checking."* / *"Let me look into that."*
* Dead silence during processing feels like the conversation froze
Speak filler **before** the slow call, not after. The user hears words while the API loads.
### 3. Confirm before acting
* Destructive or high-stakes actions need a voice confirmation
* *"Cancel Team Standup? Say yes to confirm."*
* Low-stakes lookups can skip confirmation — just do it
Use [`run_confirmation_loop()`](/api-sdk/sdk-reference) for confirmations — it handles the yes/no loop for you.
### 4. Expect messy input
* Transcription isn't perfect. Users say "um", trail off, repeat themselves
* Use the LLM to extract clean data from noisy transcription
* If you can't parse it, ask again: *"I didn't catch that — could you say it again?"*
Never fail silently. A confused response is better than no response.
### 5. Handle exits
* If your Ability loops, give users a way out
* Check for exit words: `done`, `stop`, `bye`, `nothing else`, `I'm good`
* **One** idle cycle = keep going. **Two** = offer to leave.
Call `resume_normal_flow()` on **every** exit path — happy path, breaks, except blocks, timeouts. The #1 bug in Abilities is forgetting it somewhere.
### 6. Spell it out
TTS will mangle emails, URLs, and number formats.
* Say **"at"** not `@`, **"dot"** not `.`
* Read phone numbers digit by digit
* Say **"10 AM"**, not `"10:00"`
### 7. Silence is a feature
* Not every moment needs a response
* User said something interesting? **Log it. Don't acknowledge it.**
* User paused for 5 seconds? That's not a prompt for you to fill
* Voice is serial — never list more than 3 items without asking
***
## Sound design
Voice Abilities aren't just speech — they're audio experiences. A well-placed sound effect communicates faster than words. The difference between a toy and a product is sound design.
### Sound effect types
| Type | When to use | Example |
| ------------------------ | ------------------------------------------- | ------------------------------------------------------ |
| **Confirmation tones** | Action completes successfully. Low-stakes. | *"Lights off"* → \[soft click] — no words needed |
| **Transition sounds** | Switching modes or states. \<1 second. | Entering Ability → \[whoosh] signals mode change |
| **Intro music / themes** | Companion and game Abilities. 2–4 sec. | Trivia → \[game-show sting] = instant mode recognition |
| **Feedback beeps** | Correct/wrong, milestones, timers | Correct → \[bright pip], wrong → \[low tone] |
| **Ambient audio** | Atmosphere under speech. −20dB below voice. | Focus mode → \[lo-fi beats], sleep → \[rain sounds] |
| **Alert / interrupt** | Watcher Abilities breaking through | Timer done → \[escalating soft alarm] |
### Principles
#### Less is more
* A single well-chosen tone beats a symphony of effects
* If every action has a sound, nothing stands out — **sound inflation kills meaning**
#### Consistency builds trust
* Same action = same sound, every time
* Users learn the audio language: *"I heard the ding, so I know it worked."*
#### Time of day awareness
* Morning sounds: bright, warm, energizing
* Evening sounds: soft, muted, calm
* Late night sounds: minimal, whisper-quiet, or absent
The same Ability should sound different at 7 AM vs. 11 PM. Time-of-day gating on alert sounds is **mandatory**.
#### Sound as progressive disclosure
* **First interaction:** sound + full speech confirmation
* **After 5 uses:** sound + abbreviated speech
* **After 20 uses:** sound only — user knows what it means
Let the sound gradually replace the words as the user learns. This is how you train subconscious familiarity.
### Anti-patterns
Becomes noise. Users stop hearing the cues.
Voice AI lives or dies on latency. Don't add latency for flourish.
Time-of-day gating is mandatory.
Fire alarms, car horns, sirens — they cause panic. Don't use them as notifications.
Mixing matters. Background audio must duck under voice.
Breaks learned association. Users stop trusting what they hear.
***
## Trigger word design
### Think in speech, not text
* Users won't say *"invoke calendar management system"*
* They'll say *"what's on my calendar"*, *"do I have a 3pm"*, *"am I free Tuesday"*
Test triggers by saying them out loud across a room. If it feels unnatural to say, nobody will say it.
### Balance coverage vs. false positives
| Trigger risk | Examples | Strategy |
| -------------------------- | ------------------------------------- | --------------------------------------------- |
| **Safe single words** | `calendar`, `reschedule`, `weather` | Unambiguous — use freely |
| **Dangerous single words** | `book`, `free`, `cancel` | Multiple meanings — use phrase-level triggers |
| **Phrase-level triggers** | `book a time`, `am I free`, `free on` | Much safer than bare words |
| **Full-sentence triggers** | `what's my day look like today` | Catches indirect queries without keywords |
### Trigger word checklist
* Include plural forms: `meeting` AND `meetings`
* Include regional variants: `what's in my diary` (UK) vs. `what's on my calendar` (US)
* Include indirect phrasings: *"what's my day look like"* has no calendar keyword
* Include natural full sentences: *"what am I doing today"*
### Read trigger context
When your Ability fires, the user was mid-conversation. Read that history to classify intent:
* *"What's on my calendar today?"* → give today's schedule
* *"Create a meeting with Sarah at 3"* → start creating immediately, no menus
Pattern: **read trigger from history → classify intent with LLM → route to handler.** Don't treat every activation the same.
***
## Ability lifecycle
### How it actually works
Having a normal conversation with the Personality.
User says something matching your Ability's trigger.
Your Ability takes over.
Whatever logic your Ability runs.
Call `resume_normal_flow()` — user is back in Main Flow.
### Key implications
* You can read conversation history from **before** your trigger
* Anything you say via `speak()` **enters the Personality's conversation history**
* You **cannot** silently inject text — the agent has to say it out loud
* You must always hand control back or the Personality goes silent
### Quick mode vs. full mode
Classify at trigger time — not after a menu. The user's phrasing tells you which experience they expect.
| User says | Mode | Why |
| ----------------------------- | ------------------------------------ | ------------------------------ |
| *"Play jazz"* | **Quick** — just do it | Phrasing is an instruction |
| *"Help me build a playlist"* | **Full** — enter an interactive loop | Phrasing invites collaboration |
| *"Turn off the lights"* | **Quick** | Direct command |
| *"Set up my evening routine"* | **Full** | Open-ended setup |
### The four Ability modes
| Mode | Trigger | Behavior | Examples |
| --------------- | ------------------ | ---------------------------------------------------- | --------------------------------------------- |
| **Interactive** | User voice trigger | Takes over conversation, hands back when done | Weather, calendar, recipe walkthrough |
| **Autonomous** | Agent-triggered | No user initiation. System decides when to fire. | Proactive weather alert, smart reminder |
| **Smart** | Agent-triggered | Works silently, surfaces questions only when needed | Email draft needing approval |
| **Watcher** | Always running | Continuous. No user input ever. Monitors everything. | Meeting note-taker, life logger, alarm system |
See [Ability Types](/ability-types) and [Background Abilities](/building-abilities/background-abilities) for the full reference.
***
## The `ability.md` pattern
Every Ability ships with an `ability.md` file — YAML frontmatter (name + description) and markdown body (instructions). **The `description` field is the ONLY field the system reads to decide when to trigger.**
Bad description = never triggers, or triggers incorrectly. This is the single most important field for Agent-triggered Abilities.
***
## Quality checklist
Before you ship:
* [ ] Every `speak()` call read aloud — does it flow?
* [ ] Filler text before every API call >1s
* [ ] `run_confirmation_loop()` before every destructive action
* [ ] Exit words handled in every loop
* [ ] `resume_normal_flow()` on every exit path
* [ ] Emails/URLs/numbers pronounced correctly
* [ ] Triggers tested by saying them out loud
* [ ] Sound effects only where they earn their place
* [ ] Time-of-day gating on any alert sound
* [ ] `ability.md` description describes **when to trigger**, not what the Ability does internally
# Always-On
Source: https://docs.openhome.com/guides/getting-started/always-on
Background Abilities that run continuously for the entire session.
Always-on Abilities run as **Background Daemons** — they start automatically when a call begins and stay alive for the whole session. **No hotword, no trigger.** They work even when the Personality is in sleep mode.
## What's possible
* **Background polling** — check a file or API on a timer
* **Proactive notifications** — interrupt the conversation when something fires
* **Scheduled tasks** — alarms and time-based events
* **Ambient monitoring** — note-taking, summarization, conversation watching
## Minimal daemon template
```python theme={"system"}
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
class YourWatcher(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
background_daemon_mode: bool = False
#{{register capability}}
async def watcher_loop(self):
while True:
# your background logic here
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.watcher_loop())
```
The file **must** be named `background.py` — no other filename will be detected.
## Three patterns to know
Only `background.py`. Continuous monitoring, logging, note-taking.
`main.py` + `background.py`. Coordinate via shared file storage. Alarm is the canonical example.
Background daemon + hot mic + Deepgram. Room-aware Ability that never sleeps.
## Next steps
* Deep dive: [Background Abilities](/building-abilities/background-abilities)
* Real examples: [Alarm template](https://github.com/openhome-dev/abilities/tree/dev/templates/Alarm) + [Background template](https://github.com/openhome-dev/abilities/tree/dev/templates/Background)
* Ideas: [Cookbook → Always-On / Watcher](/building-abilities/cookbook#always-on-and-watcher)
# Ambient AI
Source: https://docs.openhome.com/guides/getting-started/ambient-ai
Always-on intelligence that listens to your environment and acts without being asked.
Ambient AI is the north star: voice AI that isn't transactional or command-based, but continuously aware of its environment. It understands the room, the sound, and the user — without being asked.
## What makes an Ability ambient
* **Listens** without a wake word — the mic stays hot via [`start_audio_recording()`](/api-sdk/sdk-reference)
* **Logs** context silently to persistent storage — no narration
* **Speaks** only when something meaningful happens — and uses `send_interrupt_signal()` first so it doesn't overlap normal conversation
> The best Ability is the one the user forgets is running — until it does something so well-timed they think: *"How did it know?"*
## The building blocks
| Building block | Used for |
| -------------------------------------------------------------------- | -------------------------------------------- |
| [Background Abilities](/building-abilities/background-abilities) | Always-on daemon that auto-starts on session |
| [Hot Mic + Deepgram](/building-abilities/hot-mic-deepgram) | Raw audio capture + analysis |
| [Persistent Memory](/guides/best-practices/persistent-memory) | Accumulate context across sessions |
| [Companion Dashboards](/guides/getting-started/companion-dashboards) | Mirror what the Ability sees on a screen |
## Ambient Ability ideas
* **Meeting Scribe** — auto-starts when 3+ voices heard, writes notes until silence
* **Baby Monitor Plus** — detects crying, unusual silence, sleep breathing
* **Life Logger** — always-on ambient capture, daily summaries to dashboard
* **Daily To-Do Compiler** — catches all *"I need to..."* mentions → list by evening
* **Gratitude Harvester** — catches positive statements, weekly gratitude list
Full catalog: [Simple Abilities Cookbook → Always-On / Watcher](/building-abilities/cookbook#always-on-and-watcher).
## Next steps
* Read [Background Abilities](/building-abilities/background-abilities) for the `background.py` pattern
* Read [Voice-First Best Practices](/guides/best-practices/voice-first) — especially the **"Silence is a feature"** rule
* Pick an idea from the [Cookbook](/building-abilities/cookbook) and ship it
# Audio LLMs
Source: https://docs.openhome.com/guides/getting-started/audio-llms
True audio intelligence — reasoning about sound itself, not just transcription.
Every AI audio feature ever built does the same thing: it listens for words and converts them to text. The audio is stripped of everything except language — the grain of a voice, the breath, the room, the texture — all discarded.
OpenHome + OpenRouter's multimodal audio unlocks something different: an AI that **genuinely listens**.
## What audio intelligence unlocks
| Domain | What the LLM hears |
| --------------------- | --------------------------------------------------------------------------- |
| **Music production** | Space between notes, tempo drift, mix imbalance — what Rick Rubin hears |
| **Home safety** | Smoke alarms, breaking glass, CO alerts by acoustic signature, not keywords |
| **Medical** | Breath sounds — wheeze, crackle, deviations from baseline |
| **Automotive** | Engine knock, rattle, bearing wear before the warning light |
| **Wildlife research** | Species identification by call, behavioral patterns |
| **Language learning** | Pronunciation, prosody, accent drift |
None of these are transcription problems. They are **listening** problems.
## The core pattern
```python theme={"system"}
import base64, requests
# 1. Capture audio with the hot mic
self.capability_worker.start_audio_recording()
await self.worker.session_tasks.sleep(10) # or wait for a stop command
self.capability_worker.stop_audio_recording()
audio_bytes = self.capability_worker.get_audio_recording()
# 2. Send to a multimodal audio model via OpenRouter
response = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": "Bearer YOUR_OPENROUTER_KEY"},
json={
"model": "google/gemini-2.5-flash-preview",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "Describe this sound. What's happening?"},
{"type": "input_audio", "input_audio": {
"data": base64.b64encode(audio_bytes).decode(),
"format": "wav",
}},
],
}],
},
timeout=30,
)
analysis = response.json()["choices"][0]["message"]["content"]
await self.capability_worker.speak(analysis)
```
## Recommended models
| Use case | Model |
| ----------------------------------- | ------------------------------------------------------- |
| General audio reasoning | `google/gemini-2.5-flash-preview` |
| Deepest audio analysis | `google/gemini-3-flash-preview` (latest multimodal) |
| Transcription-focused (speech only) | [Deepgram Nova-3](/building-abilities/hot-mic-deepgram) |
For the full model matrix, see the [SDK Reference](/api-sdk/sdk-reference#openrouter-models).
## Two-pass analysis
Multimodal audio can be slow (10–15s). Use the [two-pass pattern](/api-sdk/sdk-reference#the-two-pass-analysis-pattern) to hide latency:
1. **Pass 1 (fire-and-forget):** send audio for general analysis while the Ability talks to the user
2. **Pass 2 (on-demand):** when the user asks a specific question, inject Pass 1's result and answer with depth
## Next steps
* [Hot Mic + Deepgram](/building-abilities/hot-mic-deepgram) — the audio-recording API that powers all of this
* [SDK Reference → Prompt patterns](/api-sdk/sdk-reference#battle-tested-prompt-patterns) — prompts 3 and 4 are the audio-analysis workhorses
* [Cookbook → Hot-mic + Deepgram showcase](/building-abilities/cookbook#hot-mic-and-deepgram-showcase) — 11 ideas already built on this pattern
# OpenHome CLI
Source: https://docs.openhome.com/guides/getting-started/cli
Build, push, test, and deploy OpenHome Abilities from your terminal, your favorite IDE, or an AI coding agent — scaffold from a template, push to your account, and call your Agent by voice.
The **OpenHome CLI** lets you build and manage Abilities from your terminal — or your favorite IDE, or an AI coding agent. Scaffold a new Ability from a template, push it to your account, set its trigger words, and voice-test it against your Agent.
OpenHome's Dashboard includes a **Live Editor** for performing these actions in the browser — editing an Ability's code, setting its trigger words, and testing it against your Agent. The CLI provides the same workflow outside the browser, so you can stay within your own editor and tooling.
As a standard command-line tool, the CLI can also be driven by AI coding agents such as **Claude Code** or **Codex**, or used inside agentic IDEs such as **Cursor** and **Antigravity**. This makes it a fast loop for **build → push → test → deploy** cycles.
The CLI lives inside the open-source [`openhome-dev/abilities`](https://github.com/openhome-dev/abilities) repository.
## Install and Set Up
Install the CLI using one of the two methods below.
**One-line install (macOS and Linux)** is the quickest way to get started. Run:
```bash theme={"system"}
curl -fsSL https://app.openhome.com/install.sh | sh
```
This installs the OpenHome CLI and makes it available in your terminal.
The one-line installer is currently in **beta** and may not work in every environment. If it fails, install from the repo using the method below and report it on [Discord](https://discord.gg/openhome).
**Installing from the repo** is the right choice if you are contributing to the abilities repo or working with Ability source directly. It requires Python 3.10 or newer:
```bash theme={"system"}
git clone https://github.com/openhome-dev/abilities.git
cd abilities
python3 -m venv cli/.venv
source cli/.venv/bin/activate # macOS / Linux
cli\.venv\Scripts\activate # Windows
pip install -e cli
cp .env.example .env
```
With either method, the OpenHome CLI becomes available from your terminal.
Authenticate the CLI with your OpenHome account:
```bash theme={"system"}
openhome login
```
Paste your API key when prompted. Find it in the Dashboard under [Settings → API Keys](https://app.openhome.com/dashboard/settings) → **OpenHome API Key**. Your credentials are saved locally, so you only log in once.
**Adding a session token (JWT).** `openhome login` prompts only for your API key, which is sufficient for everyday commands such as `agents`, `list`, `call`, and `chat`. Uploading an Ability's code with `openhome create` or `openhome push` may additionally require a JWT — your browser session token. If an authentication error occurs while pushing an Ability, provide a JWT explicitly:
```bash theme={"system"}
openhome login --api-key --jwt
```
To get your JWT, open [app.openhome.com](https://app.openhome.com) while logged in, then either:
* Run this in the browser console to copy it to your clipboard:
```js theme={"system"}
copy(localStorage.getItem('access_token'))
```
* Or open **DevTools → Application → Local Storage**, find `access_token`, and copy its value.
Voice calls (`openhome call`) use the `mpv` player and PortAudio. Install them so you can talk to your Agent:
```bash theme={"system"}
# macOS
brew install mpv portaudio
# Linux
sudo apt install mpv portaudio19-dev
```
## The Core Loop
Building an Ability with the CLI follows a repeatable loop: scaffold it from a template, implement your logic, push your code to test it, and commit a version once it works. Each step is a single command, so you can iterate quickly without opening the Dashboard.
```bash theme={"system"}
openhome create my-weather -t api-template
```
This scaffolds the Ability into your local `user/` workspace and pushes the initial version directly to your account, where it appears in your Abilities list. You'll be prompted for trigger words and a description (or pass them with `--triggers` and `--description`).
Your `user/` workspace is private to you — it's gitignored and never committed. You don't need to track Ability IDs yourself; the CLI remembers which account Ability each folder belongs to, so `openhome push` updates the right one in place instead of creating duplicates.
Open `user/my-weather/main.py` and implement your Ability's logic. This file is the entry point for your Ability.
As you make changes, push your current code to your account:
```bash theme={"system"}
openhome push user/my-weather
```
This updates your Ability in place with the current contents of your folder. It does **not** create a committed version; it updates the working code so you can test it.
Talk to your Agent and trigger the Ability with one of its trigger words:
```bash theme={"system"}
openhome call # real voice call
openhome chat # text chat in the terminal
```
Once the Ability behaves as intended, commit a versioned release:
```bash theme={"system"}
openhome push user/my-weather --commit -m "v2: better forecasts"
```
Committing saves a numbered version of your Ability (v1 → v2 → …) that you can continue to iterate on.
## Command Reference
### Account
| Command | What it does |
| -------------------- | ---------------------------------------------------------------------- |
| `openhome login` | Verifies and saves your credentials so you only sign in once. |
| `openhome agents` | Lists your Agents with their IDs and names. |
| `openhome templates` | Lists the templates and official example Abilities you can start from. |
| `openhome list` | Lists your Abilities with their IDs, state, and trigger words. |
### Create & Update
| Command | What it does |
| -------------------------------------------- | ------------------------------------------------------------------------------------- |
| `openhome create -t ` | Scaffolds a new Ability from a template and pushes the first version to your account. |
| `openhome push ` | Pushes your current code, updating the Ability in place without committing a version. |
| `openhome push --commit -m ""` | Commits the current code as a new version (v1 → v2). |
### Trigger Words & State
| Command | What it does |
| ------------------------------------------------ | ------------------------------------------------ |
| `openhome set-triggers "weather, forecast"` | Replaces an Ability's trigger words. |
| `openhome enable ` | Enables an Ability so your Agent can trigger it. |
| `openhome disable ` | Disables an Ability so no Agent can trigger it. |
### Voice & Chat
| Command | What it does |
| -------------------------- | ------------------------------------------------------------------------------- |
| `openhome call [agent-id]` | Real voice call (mic + speaker) to your default Agent, or a specific one by ID. |
| `openhome chat [agent-id]` | Interactive text chat with an Agent (needs an ID or `OPENHOME_AGENT_ID`). |
### Sync & Remove
| Command | What it does |
| ---------------------- | -------------------------------------------------------------------------- |
| `openhome sync` | Downloads your account's Abilities into `user/`, keeping your local edits. |
| `openhome delete ` | Removes an Ability from your account and locally. |
### Contribute
| Command | What it does |
| ----------------------------------- | ----------------------------------------------------------------------- |
| `openhome push_to_community ` | Copies an Ability from `user/` into `community/` for a contribution PR. |
### Local Link
| Command | What it does |
| ----------------------- | ------------------------------------------------- |
| `openhome local start` | Starts the Local Link bridge in the background. |
| `openhome local status` | Shows whether the bridge is running. |
| `openhome local logs` | Streams the bridge's requests and responses live. |
| `openhome local stop` | Stops the background bridge. |
| `openhome local run` | Runs the bridge in the foreground for debugging. |
**Trigger words** are the spoken phrases that activate your Ability. Set them when you create an Ability, or update them at any time with `openhome set-triggers`.
## Calling & Chatting with Your Agent
The CLI provides two ways to test an Ability against your Agent without leaving the terminal: a real voice call, or a text chat.
### Voice call
```bash theme={"system"}
openhome call # call your default Agent
openhome call 238371 # call a specific Agent by ID
```
`openhome call` opens a live voice session: it streams your microphone to the Agent and plays the Agent's spoken reply back through your speakers. With no ID it connects to your default Agent; pass an Agent ID to call a specific one.
During a call:
* **Speak after the greeting** — talk naturally, and the Agent responds in voice.
* **Press SPACE** to interrupt the Agent while it's talking.
* **Press Ctrl-C** to hang up.
The call also streams **live logs** in your terminal as it runs, color-coded by level. Any logging you add inside your Ability appears here in real time, making `openhome call` a convenient way to observe your Ability's behavior and debug it during a conversation.
Voice calls need `mpv` installed (see [Install and Set Up](#install-and-set-up)).
### Text chat
```bash theme={"system"}
openhome chat 238371 # text chat with a specific Agent
```
`openhome chat` opens an interactive text session in your terminal — type a message, press Enter, and the Agent's reply prints back. It is the same conversation as a voice call, in plain text, and requires no audio setup. Type `/quit` to exit.
Chat requires an Agent ID, passed directly or set via the `OPENHOME_AGENT_ID` environment variable.
## Sync Your Account
If you built Abilities in the Dashboard, or want them on a new machine, `sync` downloads every Ability on your account into your `user/` workspace:
```bash theme={"system"}
openhome sync # download your account's Abilities (keeps local edits)
openhome sync --force # overwrite local code with the account version
openhome sync --prune # also remove local folders for Abilities deleted on your account
```
By default `sync` only adds and updates — it never deletes your local work. Use `--force` to pull the account's version over your local code, and `--prune` to mirror deletions you made on the account.
## Contributing to the Community
Once your Ability is finished, tested, and behaving as intended, you can share it with the open-source community. The `push_to_community` command stages it for a pull request to the [`openhome-dev/abilities`](https://github.com/openhome-dev/abilities) repository:
```bash theme={"system"}
openhome push_to_community my-weather
```
This takes your finished Ability from `user/` and:
1. **Copies it into the repo's `community/` folder**, stripping personal and build files (your local manifest, caches, and zips) so only the source is shared.
2. **Runs the repo's validator** to check it meets the contribution rules, and flags anything to fix before you open a PR.
3. **Prints the git steps** to create a branch, commit, and open your pull request.
Community folder names must use **hyphens only** — no underscores or spaces. This step is entirely separate from your account: it prepares a contribution locally and does **not** change anything on app.openhome.com. See [Contributing](/community/contributing) for the full review and submission guide.
## Local Link
`openhome local` runs a small bridge on your computer that stays connected to your Agent. When your Agent needs something done on your machine, it sends the request to the bridge, the bridge runs it, and the reply goes back to the Agent — so a voice Agent can reach your computer without going through the cloud sandbox.
Each request is routed to whichever local handler is available:
* **local-link** — a raw shell executor (first-class on macOS and Linux, best-effort on Windows). Always available.
* **hermes** — used when Hermes is installed and configured.
* **openclaw** — used when OpenClaw is installed and its gateway is running.
The bridge detects which handlers are ready and reports them to the Agent. Anything installed but not yet usable returns a short hint on how to enable it — for example, starting the OpenClaw gateway.
```bash theme={"system"}
openhome local start # start the bridge in the background
openhome local status # check whether it's running
openhome local logs # stream requests and responses live (Ctrl-C to stop)
openhome local stop # stop it
openhome local run # run in the foreground for debugging (Ctrl-C to quit)
```
`start` and `run` accept the following options:
| Flag | Default | Description |
| ------------- | -------- | -------------------------------- |
| `--client-id` | `laptop` | A name for this device. |
| `--role` | `agent` | The connection role. |
| `--timeout` | `30` | Per-request timeout, in seconds. |
`openhome local logs` shows recent history and then live-tails. Pass `--no-follow` to print recent logs and exit, or `-n` / `--lines ` to change how much history it shows first. The background bridge reconnects on its own if the connection drops; `openhome local run --once` connects a single time without reconnecting, which is useful while debugging.
## Troubleshooting
Real voice calls require the `mpv` player. Install it with `brew install mpv portaudio` (macOS) or `sudo apt install mpv portaudio19-dev` (Linux). If you prefer not to install it, use `openhome chat` for a text-based conversation instead.
The CLI couldn't find a valid API key. Run `openhome login` and paste your key, or set `OPENHOME_API_KEY` in your `.env` file. Get your key from **Settings → API Keys** at [app.openhome.com](https://app.openhome.com).
Uploads are validated against OpenHome's Ability rules — for example, only approved imports are allowed. Check that your `main.py` follows the template structure and doesn't use disallowed imports, then push again.
## See Also
* [Ability Templates](/building-abilities/templates) — the starter blueprints you scaffold from
* [How to Build an Ability](/building-abilities/how-to-build) — writing your Ability's logic
* [Marketplace](/marketplace) — how Abilities reach users
* [Contributing](/community/contributing) — sharing your Ability with the community
# Companion Dashboards
Source: https://docs.openhome.com/guides/getting-started/companion-dashboards
Pair your OpenHome Ability with a live web dashboard built on Replit.
Ambient AI deserves a screen. Companion dashboards are front-ends that render what your Ability sees — life logs, meeting summaries, sensor feeds, smart-home state.
## The architecture in one sentence
Your Ability fires HTTP POSTs to a Flask server you host on Replit, which keeps the latest state in memory and serves it to a polling frontend.
```
OpenHome Ability ──POST──▶ Replit Flask server ◀──poll── Browser dashboard
(Python) (in-memory store) (HTML + JS)
```
## Pipeline in under 20 minutes
One thing — a room's emotional tone, meeting takeaways, sensor readings. Resist adding more up front.
Start from a template. Stub a main loop that prints what it sees.
Blank Python Repl. Flask server. One POST endpoint that echoes to the console. Copy the public URL.
Paste the URL into the Ability as `DASHBOARD_URL`. Add a fire-and-forget POST helper. Call from the main loop.
Run both sides. Watch the packets land in both logs.
Once the pipe is proven, build the UI. The backend should never change again.
For the deep dive on rules, patterns, and snippets, see [Build a Companion Dashboard](/guides/best-practices/companion-dashboard).
# LLM Support
Source: https://docs.openhome.com/guides/getting-started/llm-support
Use any LLM available through OpenRouter with your OpenHome Agent.
OpenHome agents can use **any LLM available through OpenRouter** — from frontier models like Claude and GPT to open-weight models like Llama and Mistral.
You pick the model when you create or edit your Agent in the [Dashboard](https://app.openhome.com/dashboard/home).
## Why OpenRouter
One API, every model. No separate billing or key management per provider.
## Bringing your own key
You can also bring your own OpenAI API key. Add it in [Dashboard → Settings → API Keys](https://app.openhome.com/dashboard/settings) and your Agents will use your key for OpenAI models. Billing happens directly through OpenAI.
# Local Abilities
Source: https://docs.openhome.com/guides/getting-started/local-ability
A special Ability type that runs on the OpenHome DevKit and can use hardware, connected peripherals, the file system, shell commands, and the device's Python environment.
Local Abilities are a specialized Ability type for running DevKit-side code from an OpenHome Ability. Unlike other Ability types, which operate only within the standard Ability runtime, Local Abilities can use the DevKit hardware, system resources, and the Python environment installed on the device.
This includes Python imports that are restricted in the standard runtime, file system operations, shell commands, hardware access such as GPIO pins, sensors, LEDs, and connected peripherals, and system-level data such as CPU, memory, temperature, and network state.
Use Local Abilities for IoT projects, custom hardware integrations, voice-controlled physical devices, device telemetry, long-running on-device tasks, and any use case that requires direct interaction with the DevKit or capabilities beyond the standard Ability runtime.
Local Abilities only run on actual OpenHome DevKit hardware. They do not run in the web Live Editor's simulated environment.
## How It Works
A Local Ability is split between the standard Ability runtime and DevKit-side execution. `main.py` handles the Agent flow, while `devkit_functions.py` runs hardware, system, and device-level code on the OpenHome DevKit.
### File Structure
| File | Runtime | Use for |
| --------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `main.py` | Standard Ability runtime | Voice interaction, prompts, conversation state, SDK calls, and calls to DevKit-side functions. |
| `devkit_functions.py` | OpenHome DevKit | Hardware control, connected peripherals, system operations, shell commands, system telemetry, ambient intelligence workflows, and DevKit-side Python packages. |
| `requirements.txt` | OpenHome DevKit | Python dependencies installed for `devkit_functions.py`. |
The DevKit-side file **must** be named exactly `devkit_functions.py`. No other filename will be picked up by the platform.
Packages listed in `requirements.txt` are installed for `devkit_functions.py` on the OpenHome DevKit. They are not available in the standard Ability runtime where `main.py` runs.
### Calling DevKit Functions
Use `send_devkit_capability_action()` in `main.py` to run a registered function from `devkit_functions.py`.
```python theme={"system"}
result = await self.capability_worker.send_devkit_capability_action(
function_name="your_function_name",
args=["arg1", "arg2"],
timeout=10,
)
```
| Parameter | Type | Description |
| ----------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `function_name` | `str` | Name of the function registered in `devkit_functions.py`. |
| `args` | `list[str]` | Arguments passed to the DevKit function. Values are passed as strings; cast them inside `devkit_functions.py` when another type is required. |
| `timeout` | `int` | Maximum number of seconds to wait for the function to complete. |
| `capability_name` | `str` *(optional)* | Name of another installed Ability whose `devkit_functions.py` should handle the call. Omit to use the current Ability. |
### `devkit_functions.py` Execution Flow
`devkit_functions.py` runs on the OpenHome DevKit as a Python script. Functions that should be callable from `main.py` must be registered in `FUNCTION_REGISTRY`, and the `function_name` passed from `main.py` must match one of those registry keys.
`devkit_functions.py` should include a Python main guard: `if __name__ == "__main__"`. The main guard reads the requested function name and arguments, then runs the matching registered function.
Values in `args` are passed to the DevKit-side function as strings. Cast them inside `devkit_functions.py` when the function requires a specific type, such as an integer, boolean, or JSON object.
Use `print()` for output that should be returned to `main.py`; standard output is captured in `result["output"]`. Python `return` values are not captured by `send_devkit_capability_action()`.
Use `web_logger` for diagnostics. These logs appear in the **DevKit** section of the Ability Live Editor and are not returned to `main.py`.
### Response Shape
`send_devkit_capability_action()` returns an object with the execution status, captured output, and request metadata.
```python theme={"system"}
{
"success": True, # True if the DevKit function completed successfully
"output": "captured stdout", # Output from print() calls in devkit_functions.py
"error": None, # Captured stderr or execution error details
"function_name": "function_name", # Function that was executed
"args": ["arg1", "arg2"], # Arguments passed to the function
"capability_name": "ability_name" # Ability that handled the request
}
```
`output` contains the standard output produced during execution. If the function does not print anything, `output` is `None`.
`error` contains the error message when execution fails. Otherwise, it is `None`.
Logs written with `web_logger` are separate from the returned object. They appear in the **DevKit** section of the Ability Live Editor logs and are useful for debugging DevKit-side execution.
### Example: Wi-Fi Status
This example reads the DevKit's current Wi-Fi connection and speaks it back to the user.
**`devkit_functions.py`** — runs on the DevKit:
```python theme={"system"}
import json
import sys
import subprocess
from devkit_utils.devkit_logging import web_logger as log
def _print_payload(payload):
output = json.dumps(payload)
log.info("stdout payload: %s", output)
print(output)
def check_wifi():
try:
result = subprocess.run(
["iwgetid", "-r"], capture_output=True, text=True, timeout=5
)
ssid = result.stdout.strip()
if ssid:
_print_payload({
"success": True,
"metric": "wifi",
"spoken_response": f"Wi-Fi is connected to {ssid}.",
"data": {"connected": True, "ssid": ssid},
"error": None,
})
else:
_print_payload({
"success": True,
"metric": "wifi",
"spoken_response": "Wi-Fi is not connected.",
"data": {"connected": False, "ssid": None},
"error": None,
})
except Exception as error:
log.exception("check_wifi failed")
_print_payload({
"success": False,
"metric": "wifi",
"spoken_response": "I couldn't read Wi-Fi status.",
"data": {},
"error": {
"code": "wifi_error",
"message": str(error),
},
})
FUNCTION_REGISTRY = {
"check_wifi": check_wifi,
}
if __name__ == "__main__":
function_name = sys.argv[1]
FUNCTION_REGISTRY[function_name](*sys.argv[2:])
```
**`main.py`** — runs in the standard Ability runtime:
```python theme={"system"}
import json
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
class WifiStatusCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
#{{register capability}}
async def first_function(self):
try:
result = await self.capability_worker.send_devkit_capability_action(
function_name="check_wifi",
args=[],
timeout=5,
)
await self.capability_worker.speak(self._spoken_response_from_result(result))
finally:
self.capability_worker.resume_normal_flow()
def _spoken_response_from_result(self, result):
if not isinstance(result, dict) or not result.get("success"):
return "I couldn't fetch Wi-Fi status from the DevKit."
output = (result.get("output") or "").strip()
if not output:
return "The DevKit did not return Wi-Fi status."
try:
payload = json.loads(output)
except json.JSONDecodeError:
return "I couldn't read the DevKit response."
return payload.get("spoken_response") or "I couldn't read Wi-Fi status."
def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self)
self.worker.session_tasks.create(self.first_function())
```
### Calling Functions from Another Local Ability
`main.py` can also call functions from another installed Local Ability's `devkit_functions.py`. This is useful when one Local Ability exposes reusable DevKit-side functions that another Ability needs to use.
`capability_name` is only needed for cross-Ability calls. When it is omitted, the call uses the current Ability's `devkit_functions.py`.
```python theme={"system"}
result = await self.capability_worker.send_devkit_capability_action(
function_name="get_sensor_value",
args=["temperature"],
timeout=10,
capability_name="target_ability_name",
)
```
To find the name of an installed Ability to use as `capability_name`, see [Installed Abilities](#installed-abilities) later in this document.
## Local Abilities in the Live Editor
### Select the Local Category
To create a Local Ability, select **Local** from the Ability categories and choose a template.
If you upload a custom Ability, the project must include `devkit_functions.py` and `requirements.txt`.
### Advanced DevKit Controls
If your DevKit is online and connected, the **Advanced DevKit Controls** toggle appears in the Ability Editor. Enable it to expand the Advanced DevKit Controls section.
Once Advanced DevKit Controls are enabled, scroll down and you will see the Advanced DevKit Controls section. From here you can sync your Ability to the DevKit, restart the Agent, and view the DevKit connection status.
### Sync Local Abilities with the DevKit
When the DevKit is online and connected, changes saved in the Live Editor are synced to the DevKit automatically.
On save:
* **`devkit_functions.py` or `requirements.txt`** changes are pushed to the DevKit without restarting the Agent. If `requirements.txt` changed, new dependencies are installed on the DevKit.
* **`main.py`** changes are saved to the OpenHome platform, synced with the DevKit sandbox, and the Agent restarts on the DevKit so the latest Ability code is used.
When editing `main.py`, save after completing the intended change. Each save can restart the Agent on the DevKit while the DevKit is connected.
If the DevKit was offline while you updated a Local Ability:
* **`main.py`** changes sync when the DevKit reconnects.
* **`devkit_functions.py` or `requirements.txt`** changes should be synced before testing. After the DevKit reconnects, click **Sync Abilities** from Advanced DevKit Controls to apply the latest changes.
You can also sync from **Advanced DevKit Controls** in the Live Editor, or from the **OpenHome - Voice AI Devkit App** dashboard using the **Sync Abilities** button .
### Logging on the DevKit
Use the DevKit logger inside `devkit_functions.py` to debug on-device behavior. Messages written with this logger appear in the **DevKit** section of the Ability Editor logs.
```python theme={"system"}
from devkit_utils.devkit_logging import web_logger as log
log.info("devkit stats functions loaded")
def check_temperature():
log.info("check_temperature: entry")
# Your DevKit-side code runs here
log.info("check_temperature: completed")
```
To view the logs, open the **DevKit** section inside the Ability Editor logs after triggering the Ability on the DevKit.
### Installed Abilities
To use functions from another Ability's `devkit_functions.py`, you need that Ability's name to pass in the `capability_name` parameter. To find it, click the **Quick Reference Installed Abilities** button in the top-left corner of the Ability Editor.
This opens the installed Local Abilities list. Copy the name of the Local Ability that contains the target `devkit_functions.py` file and pass it in the `capability_name` parameter.
## Example: DevKit Stats
This is a voice-controlled DevKit telemetry reporter. Users say something like *"check cpu"* or *"how hot is the devkit"* and the DevKit reads its system stats and speaks them back.
### Trigger words
This example can be triggered with phrases like:
* `devkit info`
* `system info`
* `how long has my devkit been running`
### `requirements.txt`
No third-party packages are required for this example — all stat checks use Python's standard library and standard Linux interfaces (`/proc`, `/sys`, and shell commands like `iwgetid`, `df`).
For other Local Abilities that need hardware libraries, list them here. Some common examples:
```
rpi-ws281x # NeoPixel / WS281x LED strip control
gpiozero # high-level GPIO pin control
RPi.GPIO # low-level GPIO access
picamera2 # camera access
adafruit-blinka # CircuitPython compatibility for sensors
smbus2 # I2C bus communication
pyserial # serial port communication
```
Only the packages you actually import in `devkit_functions.py` need to go here — they get installed on the DevKit side when you sync.
### `main.py` — standard Ability runtime
````python theme={"system"}
import json
import re
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
AVAILABLE_STATS = {
"get_cpu": "CPU usage",
"get_memory": "Memory usage",
"get_temperature": "Device temperature",
"get_uptime": "Device uptime",
"get_wifi": "Wi-Fi connection",
"get_disk": "Disk usage",
"get_health": "Overall device health",
"get_all_stats": "Summary of all key metrics",
}
FUNCTIONS_DESCRIPTION = "\n".join(
f"- {name}: {description}" for name, description in AVAILABLE_STATS.items()
)
SYSTEM_PROMPT = f"""You are a request router for a DevKit telemetry Ability. Your sole responsibility is to map user input to exactly one function name. You do not answer questions, explain concepts, or generate conversational responses.
## Device Context
The OpenHome DevKit is the user's locally connected device. Telemetry refers to its live runtime metrics: CPU, memory, temperature, uptime, Wi-Fi, disk, and health. This Ability is limited strictly to the functions listed below.
## Response Format
Always return a single JSON object. No prose, no markdown, no extra keys.
{{"function_name": ""}}
## Available Functions
{FUNCTIONS_DESCRIPTION}
## Routing Rules
- General status, "all stats", "everything", "snapshot", "system info" -> get_all_stats
- CPU, processor, load, compute, busy, usage -> get_cpu
- Memory, RAM, available memory, used memory -> get_memory
- Temperature, temp, heat, thermal, hot, warm -> get_temperature
- Uptime, boot time, running time, how long running -> get_uptime
- Wi-Fi, wifi, network, SSID, connection -> get_wifi
- Disk, storage, free space, used space -> get_disk
- Health, diagnostics, issues, problems, anything wrong -> get_health
## Exit Routing
Trigger `exit` when the user says: stop, quit, cancel, end, done, all done, that's all, thank you, thanks, goodbye, bye — or any close variation, even with filler words.
## Unsupported Requests
If the request is unrelated to DevKit telemetry, or asks for telemetry not covered by any available function, return:
{{"function_name": "none"}}
## Hard Rules
- Return exactly one function_name per response.
- Never explain, define, or discuss any concept — even if directly asked.
- Route by intent: if the user asks "what is my CPU usage?" that is a CPU telemetry request -> get_cpu.
- Do not include any text outside the JSON object.
"""
class DevKitStatsCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
#{{register capability}}
async def first_function(self):
try:
is_first_turn = True
conversation_history = []
while True:
if is_first_turn:
user_message = await self.capability_worker.wait_for_complete_transcription()
else:
user_message = await self.capability_worker.user_response()
if not user_message or not user_message.strip():
continue
route = self._route_to_devkit_function(user_message, conversation_history)
function_name = route.get("function_name", "")
if is_first_turn and function_name in ("", "none"):
function_name = "get_all_stats"
if function_name == "exit":
await self.capability_worker.speak("Exiting DevKit stats.")
break
if function_name not in AVAILABLE_STATS:
await self.capability_worker.speak(
"I can't fetch that DevKit information. Try asking for CPU, memory, temperature, disk, uptime, Wi-Fi, or health."
)
is_first_turn = False
continue
result = await self.capability_worker.send_devkit_capability_action(
function_name=function_name,
args=[],
timeout=8,
)
spoken_message = self._spoken_response_from_result(result)
await self.capability_worker.speak(spoken_message)
conversation_history.append({"role": "user", "content": user_message})
conversation_history.append({"role": "assistant", "content": spoken_message})
conversation_history = conversation_history[-12:]
await self.capability_worker.speak("Want me to check anything else, or say stop to exit.")
is_first_turn = False
except Exception as error:
self.worker.editor_logging_handler.error(f"DevKit stats failed: {error}")
await self.capability_worker.speak("Something went wrong while checking DevKit stats.")
finally:
self.capability_worker.resume_normal_flow()
def _route_to_devkit_function(self, user_message, conversation_history):
response = self.capability_worker.text_to_text_response(
f'User request: "{user_message}"',
conversation_history,
system_prompt=SYSTEM_PROMPT,
)
cleaned = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", response.strip())
try:
return json.loads(cleaned)
except (json.JSONDecodeError, TypeError, ValueError):
return {"function_name": ""}
def _spoken_response_from_result(self, result):
if not isinstance(result, dict):
return "I couldn't reach the DevKit."
if not result.get("success"):
self.worker.editor_logging_handler.error(
f"DevKit call failed: {result.get('error')}"
)
return "I couldn't fetch that DevKit information. Try asking for another stat."
output = (result.get("output") or "").strip()
if not output:
return "I couldn't fetch that DevKit information. Try asking for another stat."
try:
payload = json.loads(output)
except json.JSONDecodeError:
self.worker.editor_logging_handler.error(f"Invalid DevKit output: {output}")
return "I couldn't read the DevKit response."
if not payload.get("success"):
error = payload.get("error") or {}
self.worker.editor_logging_handler.warning(
f"DevKit stat unavailable: {error.get('code')} {error.get('message')}"
)
return payload.get("spoken_response") or "I couldn't read that DevKit stat."
def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self)
self.worker.session_tasks.create(self.first_function())
````
### `devkit_functions.py` — DevKit-side telemetry
```python theme={"system"}
import json
import shutil
import subprocess
import sys
import time
from devkit_utils.devkit_logging import web_logger as log
def _emit_success(metric, spoken, data=None):
payload = {
"success": True,
"metric": metric,
"spoken_response": spoken,
"data": data or {},
"error": None,
}
serialized_payload = json.dumps(payload)
log.info("stdout payload: %s", serialized_payload)
print(serialized_payload)
def _emit_error(metric, code, message, spoken):
log.error("%s failed [%s]: %s", metric, code, message)
payload = {
"success": False,
"metric": metric,
"spoken_response": spoken,
"data": {},
"error": {
"code": code,
"message": message,
},
}
serialized_payload = json.dumps(payload)
log.info("stdout payload: %s", serialized_payload)
print(serialized_payload)
def _read_text_file(path):
try:
with open(path, "r", encoding="utf-8") as file_handle:
return file_handle.read().strip()
except (FileNotFoundError, PermissionError, OSError) as error:
log.warning("Could not read %s: %s", path, error)
return ""
def _run_command(command, timeout=5):
try:
completed = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
log.warning("Command timed out: %s", command)
return ""
except OSError as error:
log.warning("Command failed: %s: %s", command, error)
return ""
if completed.returncode != 0:
log.warning("Command returned %s: %s", completed.returncode, command)
return ""
return completed.stdout.strip()
def _safe_int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
def _safe_float(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def _read_memory_kb(field_name):
meminfo = _read_text_file("/proc/meminfo")
for line in meminfo.splitlines():
if line.startswith(field_name):
value = line.split(":", 1)[1].strip().split()[0]
return _safe_int(value)
return None
def _read_cpu_sample():
stat = _read_text_file("/proc/stat")
for line in stat.splitlines():
if line.startswith("cpu "):
values = [_safe_int(value) or 0 for value in line.split()[1:]]
if len(values) < 4:
return None
idle = values[3] + (values[4] if len(values) > 4 else 0)
return {"idle": idle, "total": sum(values)}
return None
def _read_cpu_usage_percent(sample_seconds=0.4):
first = _read_cpu_sample()
time.sleep(sample_seconds)
second = _read_cpu_sample()
if not first or not second:
return None
total_delta = second["total"] - first["total"]
idle_delta = second["idle"] - first["idle"]
if total_delta <= 0:
return None
return round((1 - idle_delta / total_delta) * 100)
def _gb_from_kb(value):
if value is None:
return None
return round(value / 1024 / 1024, 1)
def _temperature_status(celsius):
if celsius < 50:
return "running cool"
if celsius < 65:
return "comfortable"
if celsius < 75:
return "warm"
if celsius < 85:
return "hot"
return "very hot"
def get_cpu():
metric = "cpu"
log.info("get_cpu called")
try:
used_percent = _read_cpu_usage_percent()
if used_percent is None:
_emit_error(metric, "cpu_unavailable", "CPU usage could not be read.", "I couldn't read CPU usage.")
return
free_percent = 100 - used_percent
_emit_success(
metric,
f"CPU is {used_percent} percent used and {free_percent} percent free.",
{"used_percent": used_percent, "free_percent": free_percent},
)
except Exception as error:
log.exception("Unhandled error in get_cpu")
_emit_error(metric, "cpu_error", str(error), "I couldn't read CPU usage.")
def get_memory():
metric = "memory"
log.info("get_memory called")
try:
total_gb = _gb_from_kb(_read_memory_kb("MemTotal:"))
available_gb = _gb_from_kb(_read_memory_kb("MemAvailable:"))
if total_gb is None or available_gb is None:
_emit_error(metric, "memory_unavailable", "Memory info could not be read.", "I couldn't read memory usage.")
return
used_gb = round(total_gb - available_gb, 1)
_emit_success(
metric,
f"Memory has {used_gb} gigabytes used out of {total_gb}, with {available_gb} gigabytes available.",
{"total_gb": total_gb, "used_gb": used_gb, "available_gb": available_gb},
)
except Exception as error:
log.exception("Unhandled error in get_memory")
_emit_error(metric, "memory_error", str(error), "I couldn't read memory usage.")
def get_temperature():
metric = "temperature"
log.info("get_temperature called")
try:
raw_value = _read_text_file("/sys/class/thermal/thermal_zone0/temp")
millicelsius = _safe_int(raw_value)
if millicelsius is None:
_emit_error(metric, "temperature_unavailable", "Temperature value could not be read.", "I couldn't read the DevKit temperature.")
return
celsius = round(millicelsius / 1000, 1)
status = _temperature_status(celsius)
_emit_success(
metric,
f"DevKit temperature is {celsius} degrees Celsius and {status}.",
{"celsius": celsius, "status": status},
)
except Exception as error:
log.exception("Unhandled error in get_temperature")
_emit_error(metric, "temperature_error", str(error), "I couldn't read the DevKit temperature.")
def get_uptime():
metric = "uptime"
log.info("get_uptime called")
try:
uptime_text = _read_text_file("/proc/uptime")
uptime_seconds = _safe_float(uptime_text.split()[0]) if uptime_text else None
if uptime_seconds is None:
_emit_error(metric, "uptime_unavailable", "Uptime could not be read.", "I couldn't read DevKit uptime.")
return
days = int(uptime_seconds // 86400)
hours = int((uptime_seconds % 86400) // 3600)
minutes = int((uptime_seconds % 3600) // 60)
if days:
spoken_duration = f"{days} days and {hours} hours"
elif hours:
spoken_duration = f"{hours} hours and {minutes} minutes"
else:
spoken_duration = f"{minutes} minutes"
_emit_success(
metric,
f"The DevKit has been running for {spoken_duration}.",
{"seconds": round(uptime_seconds), "days": days, "hours": hours, "minutes": minutes},
)
except Exception as error:
log.exception("Unhandled error in get_uptime")
_emit_error(metric, "uptime_error", str(error), "I couldn't read DevKit uptime.")
def get_wifi():
metric = "wifi"
log.info("get_wifi called")
try:
ssid = _run_command("iwgetid -r 2>/dev/null")
if not ssid:
_emit_success(metric, "Wi-Fi is not connected.", {"connected": False, "ssid": None})
return
_emit_success(metric, f"Wi-Fi is connected to {ssid}.", {"connected": True, "ssid": ssid})
except Exception as error:
log.exception("Unhandled error in get_wifi")
_emit_error(metric, "wifi_error", str(error), "I couldn't read Wi-Fi status.")
def get_disk():
metric = "disk"
log.info("get_disk called")
try:
total_bytes, used_bytes, free_bytes = shutil.disk_usage("/")
total_gb = round(total_bytes / 1_000_000_000, 1)
used_gb = round(used_bytes / 1_000_000_000, 1)
free_gb = round(free_bytes / 1_000_000_000, 1)
used_percent = round((used_bytes / total_bytes) * 100)
_emit_success(
metric,
f"Disk is {used_percent} percent used, with {free_gb} gigabytes free.",
{
"total_gb": total_gb,
"used_gb": used_gb,
"free_gb": free_gb,
"used_percent": used_percent,
},
)
except Exception as error:
log.exception("Unhandled error in get_disk")
_emit_error(metric, "disk_error", str(error), "I couldn't read disk usage.")
def get_health():
metric = "health"
log.info("get_health called")
try:
issues = []
data = {}
raw_temperature = _safe_int(_read_text_file("/sys/class/thermal/thermal_zone0/temp"))
if raw_temperature is not None:
celsius = round(raw_temperature / 1000, 1)
data["temperature_celsius"] = celsius
if celsius >= 75:
issues.append(f"temperature is high at {celsius} degrees Celsius")
available_kb = _read_memory_kb("MemAvailable:")
if available_kb is not None:
available_mb = round(available_kb / 1024)
data["memory_available_mb"] = available_mb
if available_mb < 200:
issues.append(f"memory is low with {available_mb} megabytes available")
disk_total, disk_used, _ = shutil.disk_usage("/")
disk_used_percent = round((disk_used / disk_total) * 100)
data["disk_used_percent"] = disk_used_percent
if disk_used_percent >= 90:
issues.append(f"disk usage is high at {disk_used_percent} percent")
data["issues"] = issues
if not issues:
_emit_success(metric, "The DevKit looks healthy.", data)
elif len(issues) == 1:
_emit_success(metric, f"I found one issue: {issues[0]}.", data)
else:
_emit_success(metric, f"I found {len(issues)} issues: {', '.join(issues[:2])}.", data)
except Exception as error:
log.exception("Unhandled error in get_health")
_emit_error(metric, "health_error", str(error), "I couldn't run the DevKit health check.")
def get_all_stats():
metric = "all_stats"
log.info("get_all_stats called")
try:
cpu_percent = _read_cpu_usage_percent()
raw_temperature = _safe_int(_read_text_file("/sys/class/thermal/thermal_zone0/temp"))
temperature_celsius = round(raw_temperature / 1000, 1) if raw_temperature is not None else None
total_memory_gb = _gb_from_kb(_read_memory_kb("MemTotal:"))
available_memory_gb = _gb_from_kb(_read_memory_kb("MemAvailable:"))
ssid = _run_command("iwgetid -r 2>/dev/null")
total_bytes, used_bytes, free_bytes = shutil.disk_usage("/")
free_disk_gb = round(free_bytes / 1_000_000_000, 1)
disk_used_percent = round((used_bytes / total_bytes) * 100)
data = {
"cpu_used_percent": cpu_percent,
"temperature_celsius": temperature_celsius,
"memory_total_gb": total_memory_gb,
"memory_available_gb": available_memory_gb,
"wifi_connected": bool(ssid),
"wifi_ssid": ssid or None,
"disk_free_gb": free_disk_gb,
"disk_used_percent": disk_used_percent,
}
spoken_parts = []
if temperature_celsius is not None:
spoken_parts.append(f"temperature is {temperature_celsius} degrees Celsius")
if cpu_percent is not None:
spoken_parts.append(f"CPU is {cpu_percent} percent used")
if available_memory_gb is not None and total_memory_gb is not None:
spoken_parts.append(f"memory has {available_memory_gb} gigabytes available")
spoken_parts.append(f"disk is {disk_used_percent} percent used")
spoken_parts.append(f"Wi-Fi is connected to {ssid}" if ssid else "Wi-Fi is not connected")
_emit_success(metric, "DevKit snapshot: " + ", ".join(spoken_parts) + ".", data)
except Exception as error:
log.exception("Unhandled error in get_all_stats")
_emit_error(metric, "all_stats_error", str(error), "I couldn't gather the DevKit snapshot.")
FUNCTION_REGISTRY = {
"get_cpu": get_cpu,
"get_memory": get_memory,
"get_temperature": get_temperature,
"get_uptime": get_uptime,
"get_wifi": get_wifi,
"get_disk": get_disk,
"get_health": get_health,
"get_all_stats": get_all_stats,
}
def main():
if len(sys.argv) < 2:
_emit_error("dispatch", "missing_function", "No function name was provided.", "No DevKit function was provided.")
sys.exit(1)
function_name = sys.argv[1]
function_args = sys.argv[2:]
function = FUNCTION_REGISTRY.get(function_name)
if function is None:
_emit_error(
"dispatch",
"unknown_function",
f"Unknown function: {function_name}",
"The requested DevKit function is not available.",
)
sys.exit(1)
try:
function(*function_args)
except TypeError as error:
log.exception("Invalid arguments for %s", function_name)
_emit_error(
function_name,
"invalid_arguments",
str(error),
"The DevKit function received invalid arguments.",
)
sys.exit(1)
except Exception as error:
log.exception("Unhandled error while running %s", function_name)
_emit_error(
function_name,
"unhandled_error",
str(error),
"The DevKit function failed unexpectedly.",
)
sys.exit(1)
if __name__ == "__main__":
main()
```
## Interaction Flow
The user starts the Ability with a trigger phrase such as *"devkit stats"* or *"check cpu"*.
`main.py` keeps the voice flow in the standard Ability runtime and uses the LLM as a strict router from natural language to a registered DevKit telemetry function.
`main.py` calls `send_devkit_capability_action()` with the selected function name, arguments, and timeout. The matching function runs on the OpenHome DevKit from `devkit_functions.py`.
`devkit_functions.py` reads the requested device data, logs diagnostics with `web_logger`, and prints a structured JSON payload. The printed payload is captured in `result["output"]`.
`main.py` parses `result["output"]`, reads `spoken_response`, and speaks the result. The structured `data` field remains available for richer logic.
The Ability prompts for another stat or exits cleanly. On exit, `main.py` calls `resume_normal_flow()` so the Agent returns to its normal flow.
## Best practices
Clean separation makes both sides easier to debug. Keep `devkit_functions.py` focused on the hardware work.
Hardware calls can block. A 5–10 second timeout is typical for lightweight actions; bump to 30 or more for long-running effects or captures.
Use the DevKit logger `web_logger` for debugging and inspect messages in the **DevKit** logs section inside the Ability Editor.
Packages listed there are installed for `devkit_functions.py`. They are not available in the sandboxed runtime where `main.py` runs.
Not every DevKit has every peripheral. Wrap hardware initialization in `try/except` and log an informative error instead of crashing — your Ability can still speak a helpful message to the user.
## See also
* [Ability Types](/ability-types) — when Local is the right choice vs. Skill, Agent Controlled, or Background Daemon
* [Background Abilities](/building-abilities/background-abilities) — for always-on monitoring that doesn't need hardware access
* [SDK Reference](/api-sdk/sdk-reference) — full method catalog
* [Voice-First Best Practices](/guides/best-practices/voice-first) — the UX rules that apply to any Ability, including Local
# OpenClaw
Source: https://docs.openhome.com/guides/getting-started/openclaw
Give your OpenHome agent the ability to control your computer.
OpenClaw lets your OpenHome agent control your local machine through voice — launch apps, monitor system status, run workflows, and more.
## Quick start
```bash theme={"system"}
npm install -g openclaw@latest
openclaw onboard --install-daemon
```
Configure with an LLM API key (OpenAI, Anthropic, etc.) during onboarding.
[Download for your OS](https://drive.google.com/drive/folders/10qK75I-bFB2D98YJ6dH3tQFsvEk44Y7-) (Windows `.exe`, macOS `.dmg`, Linux AppImage).
Run the client, paste your [OpenHome API key](https://app.openhome.com/dashboard/settings), click Connect.
Add the OpenClaw template from the [Abilities library](https://app.openhome.com/dashboard/abilities) to your Agent.
For the full setup walkthrough, customization options, and example abilities, see [Connect to OpenClaw](/building-abilities/openclaw).
# Vibe Coding
Source: https://docs.openhome.com/guides/getting-started/vibe-coding
Build OpenHome Abilities and companion dashboards with AI coding assistants — Claude, Cursor, and the Replit Agent.
Vibe coding is the fastest way to ship OpenHome. Let Claude (or any coding agent) drive the build while you describe what you want in plain English. With the right briefing, you can go from idea to live Ability in an afternoon.
There are two main surfaces:
Scaffold, deploy, and test Abilities from your terminal using the OpenHome CLI. Claude writes the Python; you review and ship.
Hand the Replit Agent a master prompt; get a working Flask + dashboard in one shot.
## The rule of two tabs
Whichever surface you're on, keep **two browser tabs open** side by side so you see both sides of the pipe at once:
| Building an Ability | Building a dashboard |
| ----------------------------------- | ----------------------------------- |
| OpenHome Live Editor (Ability logs) | OpenHome Live Editor (Ability logs) |
| Your terminal with Claude + the CLI | Replit (server logs) |
If a request fails, you see it in both within a second — the fastest feedback loop voice AI has ever had.
## What makes OpenHome good for vibe coding
* **Clear sandbox rules** — a tight list of allowed/blocked APIs means the LLM has unambiguous constraints to respect
* **Consistent SDK** — every method on `self.capability_worker` or `self.worker`; easy to brief a model on
* **Deep source material** — [SDK Reference](/api-sdk/sdk-reference) + [Simple Abilities Cookbook](/building-abilities/cookbook) fit in a single LLM context window
* **CLI + API** — the [OpenHome CLI](/guides/getting-started/cli) gives agents a tool surface to drive the entire build → deploy → test loop
## Next steps
* [Vibe Coding Tips](/guides/best-practices/vibe-coding-tips) — prompts, patterns, and guardrails for both surfaces
* [OpenHome CLI](/guides/getting-started/cli) — the Claude-friendly CLI
* [Companion Dashboards](/guides/getting-started/companion-dashboards) — the architecture path for dashboard-heavy builds
# Introduction
Source: https://docs.openhome.com/introduction
Build voice agents with OpenHome, an open-source Voice AI platform.
OpenHome is an open-source Voice AI platform. Build a voice agent in five minutes on [app.openhome.com](https://app.openhome.com/), extend it with Abilities, and ship it on the OpenHome DevKit.
**Working with AI agents?** Add `.md` to any docs URL to get its plain Markdown, or fetch [docs.openhome.com/llms.txt](https://docs.openhome.com/llms.txt) for the full index and [docs.openhome.com/llms-full.txt](https://docs.openhome.com/llms-full.txt) for the complete documentation in a single file.
## Start here
Build your first Agent in five minutes.
The web control surface for your Agents and Abilities.
Extend your Agent with Python plugins.
Every SDK method you need, in one place.
## How OpenHome works
Every OpenHome speaker runs an **Agent** that combines an LLM, a voice, and a prompt. Agents can be extended with **Abilities** (plugins) and shared on the **Marketplace**.
Customizable AI voice entities that combine an LLM, a voice, and a prompt.
Plugins that extend what an Agent can do.
Publish and install community-built Agents and Abilities.
## The dynamic loop
OpenHome doesn't just respond. It evolves. Every interaction feeds back into the Agent, making the next response more tailored to how you actually talk, what you care about, and how you live.
```mermaid theme={"system"}
graph LR
A[User speaks] --> B[Audio module]
B --> C[Agent + LLM]
C --> D[Abilities]
D --> E[Response]
E --> F[Text-to-speech]
F --> G[Agent evolves]
G --> A
```
## Pick your path
Onboard and manage your OpenHome Devkit with the OpenHome - Voice AI Devkit App.
Create a voice-powered version of yourself in the AI Twin app.
Write Python plugins that give your Agent new powers.
Pair your Ability with a live web UI via Replit.
## Community
Join the builders shipping voice-first AI. [Discord](https://discord.com/channels/1197724389630824508/1201669938126008350) · [GitHub](https://github.com/openhome-dev/abilities/tree/dev) · [Blog](https://openhome.com/blog)
# Local Abilities
Source: https://docs.openhome.com/local-ability
A special Ability type that runs on the OpenHome DevKit and can use hardware, connected peripherals, the file system, shell commands, and the device's Python environment.
Local Abilities are a specialized Ability type for running DevKit-side code from an OpenHome Ability. Unlike other Ability types, which operate only within the standard Ability runtime, Local Abilities can use the DevKit hardware, system resources, and the Python environment installed on the device.
This includes Python imports that are restricted in the standard runtime, file system operations, shell commands, hardware access such as GPIO pins, sensors, LEDs, and connected peripherals, and system-level data such as CPU, memory, temperature, and network state.
Use Local Abilities for IoT projects, custom hardware integrations, voice-controlled physical devices, device telemetry, long-running on-device tasks, and any use case that requires direct interaction with the DevKit or capabilities beyond the standard Ability runtime.
Local Abilities only run on actual OpenHome DevKit hardware. They do not run in the web Live Editor's simulated environment.
## How It Works
A Local Ability is split between the standard Ability runtime and DevKit-side execution. `main.py` handles the Agent flow, while `devkit_functions.py` runs hardware, system, and device-level code on the OpenHome DevKit.
### File Structure
| File | Runtime | Use for |
| --------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `main.py` | Standard Ability runtime | Voice interaction, prompts, conversation state, SDK calls, and calls to DevKit-side functions. |
| `devkit_functions.py` | OpenHome DevKit | Hardware control, connected peripherals, system operations, shell commands, system telemetry, ambient intelligence workflows, and DevKit-side Python packages. |
| `requirements.txt` | OpenHome DevKit | Python dependencies installed for `devkit_functions.py`. |
The DevKit-side file **must** be named exactly `devkit_functions.py`. No other filename will be picked up by the platform.
Packages listed in `requirements.txt` are installed for `devkit_functions.py` on the OpenHome DevKit. They are not available in the standard Ability runtime where `main.py` runs.
### Calling DevKit Functions
Use `send_devkit_capability_action()` in `main.py` to run a registered function from `devkit_functions.py`.
```python theme={"system"}
result = await self.capability_worker.send_devkit_capability_action(
function_name="your_function_name",
args=["arg1", "arg2"],
timeout=10,
)
```
| Parameter | Type | Description |
| ----------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `function_name` | `str` | Name of the function registered in `devkit_functions.py`. |
| `args` | `list[str]` | Arguments passed to the DevKit function. Values are passed as strings; cast them inside `devkit_functions.py` when another type is required. |
| `timeout` | `int` | Maximum number of seconds to wait for the function to complete. |
| `capability_name` | `str` *(optional)* | Name of another installed Ability whose `devkit_functions.py` should handle the call. Omit to use the current Ability. |
### `devkit_functions.py` Execution Flow
`devkit_functions.py` runs on the OpenHome DevKit as a Python script. Functions that should be callable from `main.py` must be registered in `FUNCTION_REGISTRY`, and the `function_name` passed from `main.py` must match one of those registry keys.
`devkit_functions.py` should include a Python main guard: `if __name__ == "__main__"`. The main guard reads the requested function name and arguments, then runs the matching registered function.
Values in `args` are passed to the DevKit-side function as strings. Cast them inside `devkit_functions.py` when the function requires a specific type, such as an integer, boolean, or JSON object.
Use `print()` for output that should be returned to `main.py`; standard output is captured in `result["output"]`. Python `return` values are not captured by `send_devkit_capability_action()`.
Use `web_logger` for diagnostics. These logs appear in the **DevKit** section of the Ability Live Editor and are not returned to `main.py`.
### Response Shape
`send_devkit_capability_action()` returns an object with the execution status, captured output, and request metadata.
```python theme={"system"}
{
"success": True, # True if the DevKit function completed successfully
"output": "captured stdout", # Output from print() calls in devkit_functions.py
"error": None, # Captured stderr or execution error details
"function_name": "function_name", # Function that was executed
"args": ["arg1", "arg2"], # Arguments passed to the function
"capability_name": "ability_name" # Ability that handled the request
}
```
`output` contains the standard output produced during execution. If the function does not print anything, `output` is `None`.
`error` contains the error message when execution fails. Otherwise, it is `None`.
Logs written with `web_logger` are separate from the returned object. They appear in the **DevKit** section of the Ability Live Editor logs and are useful for debugging DevKit-side execution.
### Example: Wi-Fi Status
This example reads the DevKit's current Wi-Fi connection and speaks it back to the user.
**`devkit_functions.py`** — runs on the DevKit:
```python theme={"system"}
import json
import sys
import subprocess
from devkit_utils.devkit_logging import web_logger as log
def _print_payload(payload):
output = json.dumps(payload)
log.info("stdout payload: %s", output)
print(output)
def check_wifi():
try:
result = subprocess.run(
["iwgetid", "-r"], capture_output=True, text=True, timeout=5
)
ssid = result.stdout.strip()
if ssid:
_print_payload({
"success": True,
"metric": "wifi",
"spoken_response": f"Wi-Fi is connected to {ssid}.",
"data": {"connected": True, "ssid": ssid},
"error": None,
})
else:
_print_payload({
"success": True,
"metric": "wifi",
"spoken_response": "Wi-Fi is not connected.",
"data": {"connected": False, "ssid": None},
"error": None,
})
except Exception as error:
log.exception("check_wifi failed")
_print_payload({
"success": False,
"metric": "wifi",
"spoken_response": "I couldn't read Wi-Fi status.",
"data": {},
"error": {
"code": "wifi_error",
"message": str(error),
},
})
FUNCTION_REGISTRY = {
"check_wifi": check_wifi,
}
if __name__ == "__main__":
function_name = sys.argv[1]
FUNCTION_REGISTRY[function_name](*sys.argv[2:])
```
**`main.py`** — runs in the standard Ability runtime:
```python theme={"system"}
import json
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
class WifiStatusCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
#{{register capability}}
async def first_function(self):
try:
result = await self.capability_worker.send_devkit_capability_action(
function_name="check_wifi",
args=[],
timeout=5,
)
await self.capability_worker.speak(self._spoken_response_from_result(result))
finally:
self.capability_worker.resume_normal_flow()
def _spoken_response_from_result(self, result):
if not isinstance(result, dict) or not result.get("success"):
return "I couldn't fetch Wi-Fi status from the DevKit."
output = (result.get("output") or "").strip()
if not output:
return "The DevKit did not return Wi-Fi status."
try:
payload = json.loads(output)
except json.JSONDecodeError:
return "I couldn't read the DevKit response."
return payload.get("spoken_response") or "I couldn't read Wi-Fi status."
def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self)
self.worker.session_tasks.create(self.first_function())
```
### Calling Functions from Another Local Ability
`main.py` can also call functions from another installed Local Ability's `devkit_functions.py`. This is useful when one Local Ability exposes reusable DevKit-side functions that another Ability needs to use.
`capability_name` is only needed for cross-Ability calls. When it is omitted, the call uses the current Ability's `devkit_functions.py`.
```python theme={"system"}
result = await self.capability_worker.send_devkit_capability_action(
function_name="get_sensor_value",
args=["temperature"],
timeout=10,
capability_name="target_ability_name",
)
```
To find the name of an installed Ability to use as `capability_name`, see [Installed Abilities](#installed-abilities) later in this document.
## Local Abilities in the Live Editor
### Select the Local Category
To create a Local Ability, select **Local** from the Ability categories and choose a template.
If you upload a custom Ability, the project must include `devkit_functions.py` and `requirements.txt`.
### Advanced DevKit Controls
If your DevKit is online and connected, the **Advanced DevKit Controls** toggle appears in the Ability Editor. Enable it to expand the Advanced DevKit Controls section.
Once Advanced DevKit Controls are enabled, scroll down and you will see the Advanced DevKit Controls section. From here you can sync your Ability to the DevKit, restart the Agent, and view the DevKit connection status.
### Sync Local Abilities with the DevKit
When the DevKit is online and connected, changes saved in the Live Editor are synced to the DevKit automatically.
On save:
* **`devkit_functions.py` or `requirements.txt`** changes are pushed to the DevKit without restarting the Agent. If `requirements.txt` changed, new dependencies are installed on the DevKit.
* **`main.py`** changes are saved to the OpenHome platform, synced with the DevKit sandbox, and the Agent restarts on the DevKit so the latest Ability code is used.
When editing `main.py`, save after completing the intended change. Each save can restart the Agent on the DevKit while the DevKit is connected.
If the DevKit was offline while you updated a Local Ability:
* **`main.py`** changes sync when the DevKit reconnects.
* **`devkit_functions.py` or `requirements.txt`** changes should be synced before testing. After the DevKit reconnects, click **Sync Abilities** from Advanced DevKit Controls to apply the latest changes.
You can also sync from **Advanced DevKit Controls** in the Live Editor, or from the **OpenHome - Voice AI Devkit App** dashboard using the **Sync Abilities** button .
### Logging on the DevKit
Use the DevKit logger inside `devkit_functions.py` to debug on-device behavior. Messages written with this logger appear in the **DevKit** section of the Ability Editor logs.
```python theme={"system"}
from devkit_utils.devkit_logging import web_logger as log
log.info("devkit stats functions loaded")
def check_temperature():
log.info("check_temperature: entry")
# Your DevKit-side code runs here
log.info("check_temperature: completed")
```
To view the logs, open the **DevKit** section inside the Ability Editor logs after triggering the Ability on the DevKit.
### Installed Abilities
To use functions from another Ability's `devkit_functions.py`, you need that Ability's name to pass in the `capability_name` parameter. To find it, click the **Quick Reference Installed Abilities** button in the top-left corner of the Ability Editor.
This opens the installed Local Abilities list. Copy the name of the Local Ability that contains the target `devkit_functions.py` file and pass it in the `capability_name` parameter.
## Example: DevKit Stats
This is a voice-controlled DevKit telemetry reporter. Users say something like *"check cpu"* or *"how hot is the devkit"* and the DevKit reads its system stats and speaks them back.
### Trigger words
This example can be triggered with phrases like:
* `devkit info`
* `system info`
* `how long has my devkit been running`
### `requirements.txt`
No third-party packages are required for this example — all stat checks use Python's standard library and standard Linux interfaces (`/proc`, `/sys`, and shell commands like `iwgetid`, `df`).
For other Local Abilities that need hardware libraries, list them here. Some common examples:
```
rpi-ws281x # NeoPixel / WS281x LED strip control
gpiozero # high-level GPIO pin control
RPi.GPIO # low-level GPIO access
picamera2 # camera access
adafruit-blinka # CircuitPython compatibility for sensors
smbus2 # I2C bus communication
pyserial # serial port communication
```
Only the packages you actually import in `devkit_functions.py` need to go here — they get installed on the DevKit side when you sync.
### `main.py` — standard Ability runtime
````python theme={"system"}
import json
import re
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker
AVAILABLE_STATS = {
"get_cpu": "CPU usage",
"get_memory": "Memory usage",
"get_temperature": "Device temperature",
"get_uptime": "Device uptime",
"get_wifi": "Wi-Fi connection",
"get_disk": "Disk usage",
"get_health": "Overall device health",
"get_all_stats": "Summary of all key metrics",
}
FUNCTIONS_DESCRIPTION = "\n".join(
f"- {name}: {description}" for name, description in AVAILABLE_STATS.items()
)
SYSTEM_PROMPT = f"""You are a request router for a DevKit telemetry Ability. Your sole responsibility is to map user input to exactly one function name. You do not answer questions, explain concepts, or generate conversational responses.
## Device Context
The OpenHome DevKit is the user's locally connected device. Telemetry refers to its live runtime metrics: CPU, memory, temperature, uptime, Wi-Fi, disk, and health. This Ability is limited strictly to the functions listed below.
## Response Format
Always return a single JSON object. No prose, no markdown, no extra keys.
{{"function_name": ""}}
## Available Functions
{FUNCTIONS_DESCRIPTION}
## Routing Rules
- General status, "all stats", "everything", "snapshot", "system info" -> get_all_stats
- CPU, processor, load, compute, busy, usage -> get_cpu
- Memory, RAM, available memory, used memory -> get_memory
- Temperature, temp, heat, thermal, hot, warm -> get_temperature
- Uptime, boot time, running time, how long running -> get_uptime
- Wi-Fi, wifi, network, SSID, connection -> get_wifi
- Disk, storage, free space, used space -> get_disk
- Health, diagnostics, issues, problems, anything wrong -> get_health
## Exit Routing
Trigger `exit` when the user says: stop, quit, cancel, end, done, all done, that's all, thank you, thanks, goodbye, bye — or any close variation, even with filler words.
## Unsupported Requests
If the request is unrelated to DevKit telemetry, or asks for telemetry not covered by any available function, return:
{{"function_name": "none"}}
## Hard Rules
- Return exactly one function_name per response.
- Never explain, define, or discuss any concept — even if directly asked.
- Route by intent: if the user asks "what is my CPU usage?" that is a CPU telemetry request -> get_cpu.
- Do not include any text outside the JSON object.
"""
class DevKitStatsCapability(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
#{{register capability}}
async def first_function(self):
try:
is_first_turn = True
conversation_history = []
while True:
if is_first_turn:
user_message = await self.capability_worker.wait_for_complete_transcription()
else:
user_message = await self.capability_worker.user_response()
if not user_message or not user_message.strip():
continue
route = self._route_to_devkit_function(user_message, conversation_history)
function_name = route.get("function_name", "")
if is_first_turn and function_name in ("", "none"):
function_name = "get_all_stats"
if function_name == "exit":
await self.capability_worker.speak("Exiting DevKit stats.")
break
if function_name not in AVAILABLE_STATS:
await self.capability_worker.speak(
"I can't fetch that DevKit information. Try asking for CPU, memory, temperature, disk, uptime, Wi-Fi, or health."
)
is_first_turn = False
continue
result = await self.capability_worker.send_devkit_capability_action(
function_name=function_name,
args=[],
timeout=8,
)
spoken_message = self._spoken_response_from_result(result)
await self.capability_worker.speak(spoken_message)
conversation_history.append({"role": "user", "content": user_message})
conversation_history.append({"role": "assistant", "content": spoken_message})
conversation_history = conversation_history[-12:]
await self.capability_worker.speak("Want me to check anything else, or say stop to exit.")
is_first_turn = False
except Exception as error:
self.worker.editor_logging_handler.error(f"DevKit stats failed: {error}")
await self.capability_worker.speak("Something went wrong while checking DevKit stats.")
finally:
self.capability_worker.resume_normal_flow()
def _route_to_devkit_function(self, user_message, conversation_history):
response = self.capability_worker.text_to_text_response(
f'User request: "{user_message}"',
conversation_history,
system_prompt=SYSTEM_PROMPT,
)
cleaned = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", response.strip())
try:
return json.loads(cleaned)
except (json.JSONDecodeError, TypeError, ValueError):
return {"function_name": ""}
def _spoken_response_from_result(self, result):
if not isinstance(result, dict):
return "I couldn't reach the DevKit."
if not result.get("success"):
self.worker.editor_logging_handler.error(
f"DevKit call failed: {result.get('error')}"
)
return "I couldn't fetch that DevKit information. Try asking for another stat."
output = (result.get("output") or "").strip()
if not output:
return "I couldn't fetch that DevKit information. Try asking for another stat."
try:
payload = json.loads(output)
except json.JSONDecodeError:
self.worker.editor_logging_handler.error(f"Invalid DevKit output: {output}")
return "I couldn't read the DevKit response."
if not payload.get("success"):
error = payload.get("error") or {}
self.worker.editor_logging_handler.warning(
f"DevKit stat unavailable: {error.get('code')} {error.get('message')}"
)
return payload.get("spoken_response") or "I couldn't read that DevKit stat."
def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self)
self.worker.session_tasks.create(self.first_function())
````
### `devkit_functions.py` — DevKit-side telemetry
```python theme={"system"}
import json
import shutil
import subprocess
import sys
import time
from devkit_utils.devkit_logging import web_logger as log
def _emit_success(metric, spoken, data=None):
payload = {
"success": True,
"metric": metric,
"spoken_response": spoken,
"data": data or {},
"error": None,
}
serialized_payload = json.dumps(payload)
log.info("stdout payload: %s", serialized_payload)
print(serialized_payload)
def _emit_error(metric, code, message, spoken):
log.error("%s failed [%s]: %s", metric, code, message)
payload = {
"success": False,
"metric": metric,
"spoken_response": spoken,
"data": {},
"error": {
"code": code,
"message": message,
},
}
serialized_payload = json.dumps(payload)
log.info("stdout payload: %s", serialized_payload)
print(serialized_payload)
def _read_text_file(path):
try:
with open(path, "r", encoding="utf-8") as file_handle:
return file_handle.read().strip()
except (FileNotFoundError, PermissionError, OSError) as error:
log.warning("Could not read %s: %s", path, error)
return ""
def _run_command(command, timeout=5):
try:
completed = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
log.warning("Command timed out: %s", command)
return ""
except OSError as error:
log.warning("Command failed: %s: %s", command, error)
return ""
if completed.returncode != 0:
log.warning("Command returned %s: %s", completed.returncode, command)
return ""
return completed.stdout.strip()
def _safe_int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
def _safe_float(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def _read_memory_kb(field_name):
meminfo = _read_text_file("/proc/meminfo")
for line in meminfo.splitlines():
if line.startswith(field_name):
value = line.split(":", 1)[1].strip().split()[0]
return _safe_int(value)
return None
def _read_cpu_sample():
stat = _read_text_file("/proc/stat")
for line in stat.splitlines():
if line.startswith("cpu "):
values = [_safe_int(value) or 0 for value in line.split()[1:]]
if len(values) < 4:
return None
idle = values[3] + (values[4] if len(values) > 4 else 0)
return {"idle": idle, "total": sum(values)}
return None
def _read_cpu_usage_percent(sample_seconds=0.4):
first = _read_cpu_sample()
time.sleep(sample_seconds)
second = _read_cpu_sample()
if not first or not second:
return None
total_delta = second["total"] - first["total"]
idle_delta = second["idle"] - first["idle"]
if total_delta <= 0:
return None
return round((1 - idle_delta / total_delta) * 100)
def _gb_from_kb(value):
if value is None:
return None
return round(value / 1024 / 1024, 1)
def _temperature_status(celsius):
if celsius < 50:
return "running cool"
if celsius < 65:
return "comfortable"
if celsius < 75:
return "warm"
if celsius < 85:
return "hot"
return "very hot"
def get_cpu():
metric = "cpu"
log.info("get_cpu called")
try:
used_percent = _read_cpu_usage_percent()
if used_percent is None:
_emit_error(metric, "cpu_unavailable", "CPU usage could not be read.", "I couldn't read CPU usage.")
return
free_percent = 100 - used_percent
_emit_success(
metric,
f"CPU is {used_percent} percent used and {free_percent} percent free.",
{"used_percent": used_percent, "free_percent": free_percent},
)
except Exception as error:
log.exception("Unhandled error in get_cpu")
_emit_error(metric, "cpu_error", str(error), "I couldn't read CPU usage.")
def get_memory():
metric = "memory"
log.info("get_memory called")
try:
total_gb = _gb_from_kb(_read_memory_kb("MemTotal:"))
available_gb = _gb_from_kb(_read_memory_kb("MemAvailable:"))
if total_gb is None or available_gb is None:
_emit_error(metric, "memory_unavailable", "Memory info could not be read.", "I couldn't read memory usage.")
return
used_gb = round(total_gb - available_gb, 1)
_emit_success(
metric,
f"Memory has {used_gb} gigabytes used out of {total_gb}, with {available_gb} gigabytes available.",
{"total_gb": total_gb, "used_gb": used_gb, "available_gb": available_gb},
)
except Exception as error:
log.exception("Unhandled error in get_memory")
_emit_error(metric, "memory_error", str(error), "I couldn't read memory usage.")
def get_temperature():
metric = "temperature"
log.info("get_temperature called")
try:
raw_value = _read_text_file("/sys/class/thermal/thermal_zone0/temp")
millicelsius = _safe_int(raw_value)
if millicelsius is None:
_emit_error(metric, "temperature_unavailable", "Temperature value could not be read.", "I couldn't read the DevKit temperature.")
return
celsius = round(millicelsius / 1000, 1)
status = _temperature_status(celsius)
_emit_success(
metric,
f"DevKit temperature is {celsius} degrees Celsius and {status}.",
{"celsius": celsius, "status": status},
)
except Exception as error:
log.exception("Unhandled error in get_temperature")
_emit_error(metric, "temperature_error", str(error), "I couldn't read the DevKit temperature.")
def get_uptime():
metric = "uptime"
log.info("get_uptime called")
try:
uptime_text = _read_text_file("/proc/uptime")
uptime_seconds = _safe_float(uptime_text.split()[0]) if uptime_text else None
if uptime_seconds is None:
_emit_error(metric, "uptime_unavailable", "Uptime could not be read.", "I couldn't read DevKit uptime.")
return
days = int(uptime_seconds // 86400)
hours = int((uptime_seconds % 86400) // 3600)
minutes = int((uptime_seconds % 3600) // 60)
if days:
spoken_duration = f"{days} days and {hours} hours"
elif hours:
spoken_duration = f"{hours} hours and {minutes} minutes"
else:
spoken_duration = f"{minutes} minutes"
_emit_success(
metric,
f"The DevKit has been running for {spoken_duration}.",
{"seconds": round(uptime_seconds), "days": days, "hours": hours, "minutes": minutes},
)
except Exception as error:
log.exception("Unhandled error in get_uptime")
_emit_error(metric, "uptime_error", str(error), "I couldn't read DevKit uptime.")
def get_wifi():
metric = "wifi"
log.info("get_wifi called")
try:
ssid = _run_command("iwgetid -r 2>/dev/null")
if not ssid:
_emit_success(metric, "Wi-Fi is not connected.", {"connected": False, "ssid": None})
return
_emit_success(metric, f"Wi-Fi is connected to {ssid}.", {"connected": True, "ssid": ssid})
except Exception as error:
log.exception("Unhandled error in get_wifi")
_emit_error(metric, "wifi_error", str(error), "I couldn't read Wi-Fi status.")
def get_disk():
metric = "disk"
log.info("get_disk called")
try:
total_bytes, used_bytes, free_bytes = shutil.disk_usage("/")
total_gb = round(total_bytes / 1_000_000_000, 1)
used_gb = round(used_bytes / 1_000_000_000, 1)
free_gb = round(free_bytes / 1_000_000_000, 1)
used_percent = round((used_bytes / total_bytes) * 100)
_emit_success(
metric,
f"Disk is {used_percent} percent used, with {free_gb} gigabytes free.",
{
"total_gb": total_gb,
"used_gb": used_gb,
"free_gb": free_gb,
"used_percent": used_percent,
},
)
except Exception as error:
log.exception("Unhandled error in get_disk")
_emit_error(metric, "disk_error", str(error), "I couldn't read disk usage.")
def get_health():
metric = "health"
log.info("get_health called")
try:
issues = []
data = {}
raw_temperature = _safe_int(_read_text_file("/sys/class/thermal/thermal_zone0/temp"))
if raw_temperature is not None:
celsius = round(raw_temperature / 1000, 1)
data["temperature_celsius"] = celsius
if celsius >= 75:
issues.append(f"temperature is high at {celsius} degrees Celsius")
available_kb = _read_memory_kb("MemAvailable:")
if available_kb is not None:
available_mb = round(available_kb / 1024)
data["memory_available_mb"] = available_mb
if available_mb < 200:
issues.append(f"memory is low with {available_mb} megabytes available")
disk_total, disk_used, _ = shutil.disk_usage("/")
disk_used_percent = round((disk_used / disk_total) * 100)
data["disk_used_percent"] = disk_used_percent
if disk_used_percent >= 90:
issues.append(f"disk usage is high at {disk_used_percent} percent")
data["issues"] = issues
if not issues:
_emit_success(metric, "The DevKit looks healthy.", data)
elif len(issues) == 1:
_emit_success(metric, f"I found one issue: {issues[0]}.", data)
else:
_emit_success(metric, f"I found {len(issues)} issues: {', '.join(issues[:2])}.", data)
except Exception as error:
log.exception("Unhandled error in get_health")
_emit_error(metric, "health_error", str(error), "I couldn't run the DevKit health check.")
def get_all_stats():
metric = "all_stats"
log.info("get_all_stats called")
try:
cpu_percent = _read_cpu_usage_percent()
raw_temperature = _safe_int(_read_text_file("/sys/class/thermal/thermal_zone0/temp"))
temperature_celsius = round(raw_temperature / 1000, 1) if raw_temperature is not None else None
total_memory_gb = _gb_from_kb(_read_memory_kb("MemTotal:"))
available_memory_gb = _gb_from_kb(_read_memory_kb("MemAvailable:"))
ssid = _run_command("iwgetid -r 2>/dev/null")
total_bytes, used_bytes, free_bytes = shutil.disk_usage("/")
free_disk_gb = round(free_bytes / 1_000_000_000, 1)
disk_used_percent = round((used_bytes / total_bytes) * 100)
data = {
"cpu_used_percent": cpu_percent,
"temperature_celsius": temperature_celsius,
"memory_total_gb": total_memory_gb,
"memory_available_gb": available_memory_gb,
"wifi_connected": bool(ssid),
"wifi_ssid": ssid or None,
"disk_free_gb": free_disk_gb,
"disk_used_percent": disk_used_percent,
}
spoken_parts = []
if temperature_celsius is not None:
spoken_parts.append(f"temperature is {temperature_celsius} degrees Celsius")
if cpu_percent is not None:
spoken_parts.append(f"CPU is {cpu_percent} percent used")
if available_memory_gb is not None and total_memory_gb is not None:
spoken_parts.append(f"memory has {available_memory_gb} gigabytes available")
spoken_parts.append(f"disk is {disk_used_percent} percent used")
spoken_parts.append(f"Wi-Fi is connected to {ssid}" if ssid else "Wi-Fi is not connected")
_emit_success(metric, "DevKit snapshot: " + ", ".join(spoken_parts) + ".", data)
except Exception as error:
log.exception("Unhandled error in get_all_stats")
_emit_error(metric, "all_stats_error", str(error), "I couldn't gather the DevKit snapshot.")
FUNCTION_REGISTRY = {
"get_cpu": get_cpu,
"get_memory": get_memory,
"get_temperature": get_temperature,
"get_uptime": get_uptime,
"get_wifi": get_wifi,
"get_disk": get_disk,
"get_health": get_health,
"get_all_stats": get_all_stats,
}
def main():
if len(sys.argv) < 2:
_emit_error("dispatch", "missing_function", "No function name was provided.", "No DevKit function was provided.")
sys.exit(1)
function_name = sys.argv[1]
function_args = sys.argv[2:]
function = FUNCTION_REGISTRY.get(function_name)
if function is None:
_emit_error(
"dispatch",
"unknown_function",
f"Unknown function: {function_name}",
"The requested DevKit function is not available.",
)
sys.exit(1)
try:
function(*function_args)
except TypeError as error:
log.exception("Invalid arguments for %s", function_name)
_emit_error(
function_name,
"invalid_arguments",
str(error),
"The DevKit function received invalid arguments.",
)
sys.exit(1)
except Exception as error:
log.exception("Unhandled error while running %s", function_name)
_emit_error(
function_name,
"unhandled_error",
str(error),
"The DevKit function failed unexpectedly.",
)
sys.exit(1)
if __name__ == "__main__":
main()
```
## Interaction Flow
The user starts the Ability with a trigger phrase such as *"devkit stats"* or *"check cpu"*.
`main.py` keeps the voice flow in the standard Ability runtime and uses the LLM as a strict router from natural language to a registered DevKit telemetry function.
`main.py` calls `send_devkit_capability_action()` with the selected function name, arguments, and timeout. The matching function runs on the OpenHome DevKit from `devkit_functions.py`.
`devkit_functions.py` reads the requested device data, logs diagnostics with `web_logger`, and prints a structured JSON payload. The printed payload is captured in `result["output"]`.
`main.py` parses `result["output"]`, reads `spoken_response`, and speaks the result. The structured `data` field remains available for richer logic.
The Ability prompts for another stat or exits cleanly. On exit, `main.py` calls `resume_normal_flow()` so the Agent returns to its normal flow.
## Best practices
Clean separation makes both sides easier to debug. Keep `devkit_functions.py` focused on the hardware work.
Hardware calls can block. A 5–10 second timeout is typical for lightweight actions; bump to 30 or more for long-running effects or captures.
Use the DevKit logger `web_logger` for debugging and inspect messages in the **DevKit** logs section inside the Ability Editor.
Packages listed there are installed for `devkit_functions.py`. They are not available in the sandboxed runtime where `main.py` runs.
Not every DevKit has every peripheral. Wrap hardware initialization in `try/except` and log an informative error instead of crashing — your Ability can still speak a helpful message to the user.
## See also
* [Ability Types](/ability-types) — when Local is the right choice vs. Skill, Agent Controlled, or Background Daemon
* [Background Abilities](/building-abilities/background-abilities) — for always-on monitoring that doesn't need hardware access
* [SDK Reference](/api-sdk/sdk-reference) — full method catalog
* [Voice-First Best Practices](/guides/best-practices/voice-first) — the UX rules that apply to any Ability, including Local
# Marketplace
Source: https://docs.openhome.com/marketplace
Publish, install, and review community Agents and Abilities.
The OpenHome Marketplace is where the community shares Agents and Abilities. Anyone can build on the [OpenHome Dashboard](https://app.openhome.com/dashboard/home) and request to publish their work for others to install, use, and review.
## How it works
Create an Agent or Ability in the [Dashboard](https://app.openhome.com/dashboard/home).
Submit your Agent or Ability for review.
Other users browse the Marketplace, install what they need, and leave reviews.
# Quickstart
Source: https://docs.openhome.com/quickstart
Welcome to the OpenHome Dashboard! This guide will walk you through the essential steps to get started with your OpenHome experience.
## Accessing the OpenHome Dashboard
### Login
* Go to the OpenHome Dashboard.
* Enter your Email and Password.
* Click `Log in` to access your account.
* Alternatively, you can sign in using Google or Apple by clicking `Sign in with Google` or `Sign in with Apple`.
### New User Registration
* If you're new to OpenHome, click `Don't have any account?` to open the signup form, where you can create a new account.
* You can also sign up using Google or Apple by clicking `Sign up with Google` or `Sign up with Apple`.
## Navigating the Home Page
### Home Dashboard Overview
* **Abilities:** Access default Abilities provided by OpenHome.
* **Agents:** View and manage pre-installed agents.
* **Marketplace:** Browse additional agents and Abilities.
### Using the Dashboard
#### Start Conversation
To start a conversation, simply navigate to the left sidebar and select one of the available agents under "My Conversations." Just click on the agent you'd like to interact with, and you'll be ready to engage in a dynamic and personalized chat.
* **Start Conversation:** The conversation will start automatically, ensuring enhance user experience. If you wish to end the conversation, simply click the orange button to stop. To start a new conversation, click the green button and continue the interaction.
* **Audio & Mic:** Use the left sky blue button to turn off speaker and right sky blue button to mute your mic.
* **Interrupt:** The interrupt button allows manual interruptions.
* **Conversation Modes:** Switch to audio-based conversations by toggling the mode button to "on." Text mode is the default, but feel free to choose audio anytime for a more dynamic experience.
* **History:** Review your conversation history displayed in the center.
* **Settings:** The profile can be found in the lower-left corner. To configure the SDK settings, simply navigate to "Settings."
#### Agent Settings
The Agent Settings panel, located on the right side of the conversation window, offers you the flexibility to adjust and fine-tune agent-related preferences. It includes options for Conversation, Behavior, and Identity Controls.
* **Conversation Settings:** Use these controls to manage how conversations behave in real time:
* **Interactive Interruption:** Lets you interrupt the agent while it is responding.
* **Auto Interruption:** Automatically handles interruptions based on interaction cues.
* **Alerts:** Turns conversation-related notifications on or off.
* **Interrupt Sensitivity:** Adjusts how easily interruptions are triggered.
* **Behavior Controls:** Customize the greeting by crafting a personalized **Starting Message**, define agent traits through a **Descriptive Prompt**, and establish the role of the agent with a clear **Purpose Prompt**.
* **Identity Controls:** Select the voice and language based on your agent preferences to create a more personalized and engaging experience.
## Agents Page
The Agents page allows you to view, manage, and create custom AI agents. You can also add new voices to customize the interaction experience further.
### Managing Agents
* **Search:** Use the search bar to find specific agents quickly.
* **Edit or Delete:** Use the icons below the agent name to edit or delete them.
### Create New Agent
* In the left sidebar, click on "Create" and choose "Agent Personality" to get started.
* You will see three creation options:
- **Quick Creation** is a fast setup path with minimal inputs.
- Complete these fields:
* **Avatar**: Upload an avatar or click **Generate with AI** to create one.
* **Name**: Enter the personality name.
* **Starting Message**: Set the default greeting shown/spoken at conversation start.
* **Description**: Add the core behavior prompt that defines how the personality responds.
* **Personality Category**: Choose the primary category (for example, Education, Companion, Home, Famous People, Games, Role Play).
* **Voice Identity**: Select a voice, optionally use **Clone voice**, and preview it with the play button.
* **Switch to PRO Mode**: Move to the full editor if you need advanced settings.
* **Save Personality**: Create the personality with the current quick setup values.
* **Create Your AI Twin** lets you create a twin with guided setup.
* Set a **Personality Image** using **Gallery**, **Camera**, or **Generate with AI**.
* Enter **Personality Name**, choose **Language**, and choose **Gender**.
* Click **Next** to start initialization.
* The system shows an **Initializing Twin Creation...** status while your twin is being prepared.
* Once initialized, you can:
* Click **Call** for voice interaction.
* Click **Message** to send text messages.
* Click **Stop** to end the current conversation.
* **Pro Creation** gives complete control over personality configuration.
* You can click the **Quick Creation** button at the top-right to switch back to the quick setup screen.
* Configure these 4 sections:
* **Personality Information**:
* **Name**: Enter a unique, identifiable name that appears in the dashboard and marketplace.
* **Marketplace Information**: Add a brief summary for marketplace display. This does not affect model behavior.
* **Avatar**: Upload an image to represent your personality, or click **Generate with AI**.
* **Key Tags**: Add categorization tags (for example, `Male`, `Anime`) to help organize discoverability.
* **Personality Behavior**:
* **Starting Message**: Initial greeting or cold-start message spoken when a conversation starts.
* **Description**: Foundation prompt that defines behavior, traits, and interaction style.
* **Prompt Modification**: Toggle whether OpenHome Builder can modify prompts for user interaction flow.
* **Publish Personality**: Toggle whether this personality is published to the marketplace.
* **Personality Category**: Choose one or more categories (for example, Education, Companion, Home, Famous People, Games, Role Play).
* **Base Ability**: Select a default Ability that auto-triggers on call initialization using **Choose Default Ability**.
* **Personality Identity**:
* **Language**: Select the language the Personality will use for communication during interactions.
* **Voice Identity**: Select a voice or add a custom Voice ID to define how the Personality sounds. You can also use **Clone voice** and preview with the play button.
* **Gender**: Choose the gender identity to shape the interaction style of the Personality.
* **Personality Platforms & Models**:
* **Speech-to-Text Platform**: Choose the transcription provider.
* **Speech-to-Text Model**: Choose the model for the selected STT platform.
* **Text-to-Speech Platform**: Choose the voice synthesis provider.
* **Text-to-Speech Model**: Choose the model for the selected TTS platform.
* **Text-to-Text Platform**: Choose the LLM provider.
* **Text-to-Text Model**: Choose the model for the selected TTT platform.
* **Randomness (Temperature)**: Slide right for more creative variation. Lower values produce more deterministic responses.
* After completing all 4 sections, click `Save Personality` to create the personality.
## Abilities Page
The Abilities page allows you to manage and customize the functionalities available in OpenHome. You can view installed Abilities, add new ones, and configure trigger words.
### Managing Abilities
* **Tabs:**
* **My Abilities:** View all custom Abilities you have created.
* **Published Abilities:** View all custom Abilities you have published.
* **Installed Abilities:** Manage all installed Abilities.
* **Add Custom Ability:** Upload a .zip file containing your code to create a custom ability.
* **Live Editor:** Enhance your created ability by utilizing the live editor for real-time modifications and improvements.
### Ability Controls
* **Enabled:** Turn the Ability on or off.
* **Agent/System Ability:** Choose whether the Ability runs as an Agent Ability or a System Ability. Enable only one of them based on your use case.
* **Trigger Words:** Use **ADD +** to add trigger phrases and use the `x` on each tag to remove them.
* **Last Updated:** View the latest update timestamp at the bottom of the Ability card.
### Add New Ability
* In the left sidebar, click on "Create" and choose "Agent Ability" to get started.
* **Ability Information:** Enter the basic details for your Ability:
* **Name**: Add a unique, identifiable Ability name that appears in the dashboard and marketplace.
* **Description**: Write a short summary to describe what the Ability does.
* **Image**: Upload an image to represent the Ability in the dashboard and marketplace.
* **Ability Behavior:** Configure how the Ability works:
* **Category**: Choose how the Ability behaves (for example, `Skill`, `Agent Controlled`, `Background Daemon`, or `Local`).
* **Template**: Pick a built-in template card, or click `Upload Custom Ability` to upload your custom Ability package.
* **API Keys**: Add required third-party keys by entering a key name and provider link, then click `+ Add`. Set key values later in **Settings > API Keys**.
* **Trigger Words**: Add trigger words/phrases that activate the Ability during conversations.
#### Finalizing
* Click `Save Ability` to add the new Ability.
## Marketplace
The Marketplace lets you discover and use Personalities, Abilities, and Voices.
### Personalities, Abilities, and Voices Tabs
* Use the top tabs to switch between **Personalities**, **Abilities**, and **Voices**.
* Use the search bar to find personalities and abilities quickly.
* In **Personalities**, you can open cards, use `Read More`, start with `Call`, filter by categories, and use `Show More` / `Show Less`.
### Featured Abilities and Voices
* **Featured Abilities** shows curated ability cards with actions like `Try It` and `Uninstall`.
* The **Voices** section provides two tabs: **OpenHome Voices** and **User Voices**.
* Each voice card includes tags and a play button to preview the voice.
### Newly Added
* The **Newly Added** area has separate rows for **Personalities** and **Abilities**.
* Each row supports search, `Find`, and sorting (for example, `Newest`).
* Use the add card (`+`) to create a new Personality or Ability from that row.
### Template Abilities
* **Built-In:** These are default templates for built-in capabilities.
#### Try Template Abilities
Click the `try it` button. A pop-up will appear where you should enter the name provided next to "Creating Template Capability."
After confirming, you will be redirected to the live editor, where you can either make changes or proceed without making any adjustments.
**Modify Files:** You can create, delete or modify files easily.
**Trigger keywords:** Trigger keywords can be checked using this button
You can also click on edit button in to modify, add, or remove trigger keywords.
**Editor Options:**
* **Commit**: Commit you changes as a new release.
* **Discard Changes**: Discard your current changes.
* **Save Changes**: Save your current changes.
* **Revert**: Revert your changes to initial release.
* **Download Folder**: Download the ability code as a zip folder in.
* **Start Live Test**: Test your changes live here.
* **Mic**: Turn on/off microphone.
* **Speaker**: Turn on/off speaker.
# Wake Word & Sleep Interaction
Source: https://docs.openhome.com/wake-sleep
Control when your Agent listens using the wake word, and put the Agent in and out of sleep mode with voice.
The **wake word** is the interaction word — or set of words — you use to address your Agent. Its purpose is to make sure the Agent only responds when you actually want it to — not to your everyday conversations with people around you, and not to speech meant for a different Agent in the same space. One or more wake words can be configured per Agent, and any one of them can be used to address it.
When the wake word is enabled, the Agent only responds to user turns that include one of the configured wake words. This is useful when:
* You have **multiple Agents** in the same room or household and want each one to respond only to its own wake words. This also prevents Agents from triggering each other — without a wake word, one Agent's spoken reply could be picked up by another Agent as user input, causing them to talk to one another in a loop.
* You want the Agent to **stay quiet during normal conversation** and only engage when explicitly addressed.
When the wake word is disabled, the Agent responds to every user turn — appropriate for focused, single-user sessions where there is no ambient conversation to filter out.
**Sleep mode** puts the Agent into a paused state where it stops responding to user turns. The Agent enters sleep mode in two ways:
* **Manually**, by speaking a sleep mode trigger phrase during the conversation (for example, `go to sleep mode` or `stop talking`).
* **Automatically**, when there is no conversation activity for the configured **sleep timeout** duration.
To resume the conversation, you must speak one of the configured wake words — this brings the Agent out of sleep mode, after which you can continue interacting with it normally. A wake word is required to exit sleep mode regardless of whether the wake word is enabled for normal conversation.
### A note on STT and short utterances
Speech-to-text (STT) often delays finalizing a transcription when the user says a single short word, since it waits for more audio to confirm the utterance is complete. This can make the wake word feel slow to respond when spoken on its own.
For example, if one of the configured wake words is `openhome`:
| User says | STT behavior |
| -------------------------------- | ------------------------------------------------------------------------- |
| `openhome` | STT waits for additional audio before finalizing, which delays detection. |
| `hey openhome, can you hear me?` | STT finalizes quickly and the wake word is detected without delay. |
This applies in both modes:
* **Normal interactive mode**: when the wake word is enabled, speak one of the configured wake words as part of a short sentence rather than alone. Say `openhome, what's the weather?` instead of pausing after `openhome`.
* **Sleep mode**: to wake the Agent, follow a configured wake word with a short phrase, for example `openhome, are you there?`, rather than saying it in isolation.
## Enable, Disable, and Change the Wake Word
The wake word can be configured from either the Dashboard or the OpenHome DevKit App. Changes apply to the Agent linked to your DevKit.
### From the Dashboard
1. Open the [Dashboard](https://app.openhome.com/dashboard/home).
2. Go to **Settings → Configuration**.
3. Toggle the wake word on or off.
4. When enabled, enter the word or phrase you want to use as the wake word. Multiple wake words can be set by separating them with commas — for example, `hello, open home` configures both `hello` and `open home` as valid wake words, and either one can be used to address the Agent.
5. Save your changes.
### From the DevKit App
1. Open the **OpenHome - Voice AI Devkit App**.
2. Go to **Profile → Configuration**.
3. Toggle the wake word on or off.
4. When enabled, enter the word or phrase you want to use as the wake word. Multiple wake words can be set by separating them with commas — for example, `hello, open home` configures both `hello` and `open home` as valid wake words, and either one can be used to address the Agent.
5. Save your changes.
Changes sync to the DevKit once it is online and connected. After changing the wake word or toggling it on or off, the Agent on the DevKit must be restarted for the new configuration to take effect. Restart the Agent from the Dashboard or the DevKit App.
## FAQ
Yes. Open **Settings → Configuration** in the Dashboard or **Profile → Configuration** in the DevKit App and toggle the wake word off. The Agent will then respond to every user turn during normal conversation. Sleep mode still requires one of the configured wake words to wake the Agent.
Yes. With the wake word enabled, enter your preferred word or phrase in **Settings → Configuration** (Dashboard) or **Profile → Configuration** (DevKit App). You can enter a single wake word or multiple comma-separated wake words. Choose words that are distinct from common conversational filler to reduce false triggers.
Yes. Enter multiple wake words separated by commas in the wake word field — for example, `hello, open home`. Each comma-separated value is treated as a distinct wake word, and any one of them can be used to address the Agent.
Check the following:
* The wake word is enabled and saved in **Settings → Configuration** (Dashboard) or **Profile → Configuration** (DevKit App).
* The DevKit is online and has synced the latest configuration.
* The wake word you spoke matches one of the configured wake words, is pronounced clearly, and is not masked by background noise.
* None of the configured wake words are too short or too similar to ambient speech, which can hurt detection accuracy.
* After changing the wake word or toggling it on or off, the Agent on the DevKit must be restarted for the new configuration to take effect. Restart the Agent from the Dashboard or the DevKit App.
## How the Interaction Flow Works
The wake word can be enabled or disabled. The behavior of the Agent during normal conversation depends on this setting. Sleep mode behavior is independent of this setting.
### Wake Word Enabled
When the wake word is enabled, the Agent only responds to user turns that contain one of the configured wake words. A wake word can appear anywhere in the utterance — it does not need to be the first word. If multiple wake words are configured, any one of them is sufficient.
For example, if the configured wake word is `please`:
| User says | Agent response |
| ---------------------------------- | ------------------------------- |
| `hey openhome how are you` | Ignored — no wake word present. |
| `please openhome how are you` | Responds — wake word detected. |
| `openhome please tell me the time` | Responds — wake word detected. |
This mode is useful in shared spaces, multi-device setups, or any environment where background speech should not trigger the Agent.
### Wake Word Disabled
When the wake word is disabled, the Agent responds to every user turn during normal conversation. No wake word is required.
This mode is useful for focused, single-user sessions where uninterrupted back-and-forth is preferred.
### Sleep Mode
Sleep mode pauses the Agent until it hears one of the configured wake words, regardless of whether the wake word is enabled for normal conversation.
1. The user triggers sleep with a Sleep Mode trigger phrase (for example, `go to sleep mode` or `stop talking`).
2. The Agent enters sleep mode and stops responding to user turns.
3. The user says one of the configured wake words. The Agent exits sleep mode and resumes normal conversation.
To wake the Agent from sleep mode, you must speak one of the configured wake words. This is the only way to bring the Agent out of sleep mode, and it applies even if you have disabled the wake word for normal conversation — sleep mode always requires a wake word to resume.
## Default Sleep Mode Trigger Words
The following are the default trigger phrases used to put the Agent into sleep mode. Speaking any of them ends the active conversation and puts the Agent into sleep mode until it hears one of the configured wake words.
| Action | Default triggers |
| ----------------------------- | ---------------------------------- |
| **Put Agent into sleep mode** | `go to sleep mode`, `stop talking` |
These trigger phrases can be changed by editing the **Sleep Mode** Ability in the Dashboard.