How to Build an OpenClaw Skill That Reads BLE Sensor Data

Most “AI meets IoT” tutorials hand you a 14-step pipeline: provision a cloud IoT hub, stand up an MQTT broker, wire up a Lambda, register a custom tool, pray the webhooks line up. For a single temperature puck on your desk? Absurd.
You don’t need any of that. If you can write a Python function, you can give an AI agent eyes on a BLE sensor in about 40 lines of code. The agent will decide when to read it, interpret the number, and answer questions like “is my office too warm for the cat?” without you writing a single prompt template.
Here’s the whole stack: OpenClaw (the skill runtime), bleak (cross-platform BLE in Python), and whatever GATT-speaking sensor you have lying around. Home Assistant and MQTT bridges are great for fleets. This is for one person, one sensor, one afternoon.
What an OpenClaw Skill Actually Is
A skill is a Python function with a decorator. That’s it. No manifest file, no YAML schema, no plugin lifecycle hooks. You write def, slap @skill on top, and the OpenClaw runtime exposes it to the agent as a callable tool.
The decorator reads your function’s signature and description, then hands them to the LLM in a form it can reason about. When the user asks a question that might need your sensor, the agent decides when to call it based on that description. You don’t schedule polls. You don’t subscribe to topics. The agent reads the sensor when reading the sensor is the right move.
This matters for IoT because sensors are bursty by nature. A cron job that reads every 30 seconds is wasteful when nobody’s asking. With an agent driving the reads, you only hit the sensor when someone actually wants to know.
The BLE Primer You Actually Need
Every BLE peripheral exposes one or more services, each identified by a UUID. Every service contains characteristics, also UUIDed, which are the actual data endpoints. You read bytes from a characteristic, decode them, done.
Two access patterns matter: read (ask for the current value) and notify (subscribe to a stream of updates). We’ll use read. It’s simpler and the agent only needs a value on demand.
To find your sensor’s UUIDs, either scan with bleak itself or use the Nordic nRF Connect mobile app. nRF Connect is the fastest way: pair, expand the services tree, copy the UUIDs.
[ BLE Sensor ]
│
Service UUID: 0000181a-0000-1000-8000-00805f9b34fb
│
Characteristic UUID: 00002a6e-... ← temperature, 2 bytes int16
│
▼
[ Your Laptop / bleak ] ──► [ OpenClaw Skill ] ──► [ AI Agent ]The UUIDs above are the standard Bluetooth SIG ones for Environmental Sensing Service and Temperature. If your device follows the spec, those work as-is. If it’s a custom peripheral (an ESP32 you flashed yourself), the UUIDs will be vendor-specific.
Project Setup
One file, one dependency each.
python -m venv .venv
source .venv/bin/activate
pip install openclaw bleakDirectory layout:
my-ble-skill/
└── sensor_skill.pyPlatform notes worth knowing before you hit a wall:
- macOS: the first run will prompt for Bluetooth permission. If you’re running from a terminal, grant the permission to your terminal app (iTerm, Terminal.app), not to Python.
- Linux: make sure
bluetoothdis running and your user is in thebluetoothgroup.bluetoothctlis useful for sanity-checking that the OS sees the device. - Windows: generally works, but Bluetooth stack quirks are real. Have nRF Connect on a phone as a fallback for UUID discovery.
Find your sensor’s MAC address with a quick scan:
import asyncio
from bleak import BleakScanner
async def scan():
for d in await BleakScanner.discover(timeout=5):
print(d.address, d.name)
asyncio.run(scan())Copy the address of your sensor. You’ll paste it into the skill next.
Writing the Skill
Here’s the whole thing. Paste it into sensor_skill.py:
from openclaw import skill
from bleak import BleakClient
import struct
DEVICE_ADDRESS = "XX:XX:XX:XX:XX:XX" # your sensor's MAC
TEMP_CHAR_UUID = "00002a6e-0000-1000-8000-00805f9b34fb"
@skill(description="Read the current temperature from my BLE sensor in Celsius.")
async def read_ble_temperature() -> float:
async with BleakClient(DEVICE_ADDRESS) as client:
raw = await client.read_gatt_char(TEMP_CHAR_UUID)
# int16, little-endian, 0.01 °C units per GATT spec
return struct.unpack("<h", raw)[0] / 100.0Line by line:
@skill(description=...). The description string is what the LLM sees. Write it like a tool label the model will use to decide “should I call this?”, not like a Python docstring for a human. “Read the current temperature from my BLE sensor in Celsius” is good. “Helper function” is not.async def. BLE is inherently async, and since OpenClaw handles async skills natively, you don’t need to wrap it inasyncio.run.BleakClient(DEVICE_ADDRESS)as a context manager. Connects on enter, cleans up on exit. For a single-shot read, this is fine. If you’re reading constantly, keep the client open (more on that below).read_gatt_charalways returns raw bytes.struct.unpack("<h", raw)[0] / 100.0. The GATT Temperature characteristic is a signed 16-bit little-endian integer in units of 0.01 °C. If your sensor uses a different encoding (custom firmware, a vendor characteristic), adjust the unpack format and scaling.
Hooking It Up to the Agent
Register the file with the OpenClaw runtime:
openclaw run sensor_skill.pyThe runtime loads the module, finds every @skill-decorated function, and starts the agent with those tools available. From here you talk to the agent however OpenClaw exposes it (CLI chat, local web UI, or an API, depending on your version).
A session looks like this:
You: What's the temperature on my desk?
Agent: (calls read_ble_temperature) → 22.47
Agent: It's about 22.5 °C on your desk, comfortable room temperature.
You: Is it warmer than it was an hour ago?
Agent: I don't have historical data, only the current reading.
Want me to log readings over time?Notice the second exchange. The agent knows what the skill can and can’t do, and it reasons about the gap. That’s the payoff for writing a clear description.
Extending It
Once one skill works, more skills are cheap.
Add humidity. Duplicate the pattern with the humidity characteristic UUID (0x2A6F in the GATT spec) and a different scaling factor. Now the agent has a second tool to pick from.
Cache reads to avoid reconnecting. BLE connection setup takes a second or two. For a chatty agent, that adds up. Cache the last value and timestamp at module level, then reconnect only when the cache is stale:
import time
_cache = {"value": None, "ts": 0}
@skill(description="Read the current temperature from my BLE sensor in Celsius.")
async def read_ble_temperature() -> float:
if time.time() - _cache["ts"] < 5 and _cache["value"] is not None:
return _cache["value"]
async with BleakClient(DEVICE_ADDRESS) as client:
raw = await client.read_gatt_char(TEMP_CHAR_UUID)
value = struct.unpack("<h", raw)[0] / 100.0
_cache.update(value=value, ts=time.time())
return valueCompose skills. Write a summarize_room_environment() skill that calls both read_ble_temperature and read_ble_humidity and returns a dict. The agent picks whichever level of detail fits the question.
Switch to notify for streaming. For heart rate or anything changing fast, use client.start_notify() in a background task and have your skill return the latest cached value. The BLE radio does the work; your skill just reads memory.
Why This Is a Great Classroom Project
A student can go from “I have a sensor” to “I’m talking to an AI about my sensor” in one afternoon. That’s rare in hands-on curricula, where most IoT projects stall on cloud setup before any learning happens.
In a single project, the student touches async Python, the BLE protocol stack, LLM tool-calling, and agent reasoning. Swap the sensor week to week (heart rate monitor, soil moisture probe, CO₂ meter) and the skill shape stays identical. Only the UUID and the decode change.
The feedback loop is visible in a way most programming exercises aren’t. You blow on the sensor, ask the agent, and watch the number change. That’s motivating in a way a passing unit test never is.
Start With One Read
Get one read working before you get fancy. A hardcoded MAC address and a single characteristic is enough to prove the loop end-to-end. Once the agent answers one question correctly, adding a second skill takes ten minutes.
If your sensor uses a custom GATT profile, spend the upfront time with nRF Connect to map its services. Undocumented characteristics are the single biggest time sink in BLE work, and a five-minute discovery session saves hours of struct.unpack guesswork.
When you’re ready to go beyond one desk (multiple devices, remote locations, a real deployment), that’s where a network like Hubble’s terrestrial BLE coverage comes in. For now, your laptop and one sensor are plenty.
Hubble Network lets your OpenClaw skills reach BLE sensors anywhere your users are, without shipping gateways or writing pairing flows. See how it works →