BLE Devices and GATT skill for Codex
GATT profiles phones actually reconnect to. Codex writes the GATT table first, with UUIDs, properties, little-endian byte layouts and security per characteristic, then tunes MTU, PHY and connection parameters against measured throughput and current. It handles bonding, iOS GATT caching and Android permission quirks, and proves the device with a bleak script that checks for dropped notifications.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- ble-devices Installs to ~/.agents/skills/ble-devices/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install ble-devices The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project ble-devices Installs to .agents/skills/ble-devices/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/ble-devices.zipunzip -o ble-devices.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) ble-devices PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $ble-devices, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs and tests Bluetooth Low Energy peripherals - GATT services and characteristics, standard versus 128-bit UUIDs, notify versus indicate, MTU and data length, connection parameters for battery life, advertising payloads, pairing and bonding with LE Secure Connections, and test scripts with Python bleak, nRF Connect and btmon. Use when designing a GATT profile for a device, notifications stop or arrive truncated, throughput is low, iOS or Android will not reconnect or shows stale services, or writing automated BLE tests.
Use it when
- Designing GATT services and characteristics for a new device
- Notifications are truncated, slow or stop after reconnecting
- Phones fail to reconnect or show stale services after a firmware update
- You need automated BLE tests against real hardware
Use something else when
- You need firmware updates delivered over BLE with signing and rollback: use firmware-ota
- Devices talk to a cloud broker rather than a phone: use mqtt-iot
How to check the result
Run the bleak script against the device for at least 30 seconds with zero sequence gaps, and confirm bonded reconnects on both an iPhone and an Android phone after a power cycle.
The mistake it prevents
Assuming a 247-byte MTU before the exchange completes: the first notifications are cut to 20 bytes and the receiver misparses them.
Done well means: the GATT table is documented with UUIDs, properties, byte layouts and security per characteristic; data moves at the throughput and power the product needs; phones reconnect after bonding, firmware updates and service changes; and an automated script proves all of it against real hardware.
Inspect first
- Read
AGENTS.md, then the stack in use: NimBLE (ESP-IDF, Apache Mynewt), Zephyr Bluetooth / nRF Connect SDK, SoftDevice, BlueZ, or a vendor stack. APIs differ; the protocol rules below do not. - Find the GATT definition (service tables,
BT_GATT_SERVICE_DEFINE,ble_gatt_svc_def) and write it out as the table below before changing it. - Ask which centrals matter (iOS, Android versions, a gateway on Linux). Their limits decide MTU, connection interval and pairing UX.
GATT design
| Characteristic | UUID | Props | Format (little-endian) | Security |
|---|---|---|---|---|
| Battery Level | 0x2A19 (SIG) | Read, Notify | uint8 percent | Open |
| Sensor Sample | a1b20001-... | Notify | int16 temp cdegC, uint16 rh 0.01 %, uint32 seq | Encrypted |
| Config | a1b20002-... | Read, Write | struct v1: uint8 ver, uint16 period_s | Encrypted + bonded |
| Command | a1b20003-... | Write | uint8 opcode, payload | Encrypted + bonded |
| Command Result | a1b20004-... | Indicate | uint8 opcode, uint8 status | Encrypted + bonded |
Rules:
- Use SIG-assigned services where one fits (Battery 0x180F, Device Information 0x180A, Heart Rate, Environmental Sensing); everything custom gets one random 128-bit base UUID with the 16-bit slot varied per characteristic.
- Binary, little-endian, fixed-size fields with a version byte. No JSON over BLE: it wastes the small packets and burns battery.
- Notify for streams (no acknowledgement, highest throughput). Indicate for rare messages that must arrive; one outstanding indication at a time.
- Each notifying characteristic has a CCCD (0x2902) the central writes to enable it. Do not send before the central subscribes; the stack drops or errors.
- Include a sequence number in streamed samples so the receiver can detect loss.
- Always include Device Information (firmware revision) and, for products, the Service Changed characteristic in the GATT service so phones refresh caches after updates.
Throughput and power
| Lever | Default | Tune to |
|---|---|---|
| ATT MTU | 23 (20 bytes payload) | Negotiate 247 (244 payload); iOS typically ends up near 185 |
| Data Length Extension | 27-byte link packets | 251 bytes when both sides support it |
| PHY | 1M | 2M for throughput, Coded for range (BLE 5) |
| Connection interval | Central decides, 7.5 ms to 4 s in 1.25 ms units | Request 15-30 ms while streaming, 500 ms-1 s while idle |
| Peripheral latency | 0 | 2-4 when idle so the peripheral can skip events |
| Supervision timeout | Central decides | Must exceed (1 + latency) x interval x 2 |
| Advertising interval | Stack default | 100 ms for fast discovery, then 1-2 s to save power |
- Payload per notification is MTU minus 3. Size messages to fit, or fragment with a header; never assume the MTU before the exchange completes.
- Phones ignore parameter requests outside their policies. iOS has published limits in Apple’s accessory design guidelines; check them rather than requesting 7.5 ms.
- Measure current with a power profiler, not estimates: advertising interval and connection latency dominate battery life.
Advertising
- Legacy advertising carries 31 bytes: flags (3), a 128-bit service UUID (18) and a short name barely fit. Put the full name and extras in the scan response.
- Advertise the primary custom service UUID so apps can filter scans (Android and iOS background scanning need it).
- Do not put secrets or stable personal identifiers in advertising data; use the stack’s resolvable private addresses when privacy matters.
Security
| Need | Pairing method |
|---|---|
| Device has no display or keypad, low risk | LE Secure Connections, Just Works (no MITM protection) |
| Device has a display | Numeric Comparison or Passkey display |
| Device has a keypad | Passkey entry |
| Out-of-band channel exists (NFC, QR) | OOB pairing |
- Require LE Secure Connections; disable legacy pairing.
- Set permissions per characteristic (encrypted, authenticated) instead of relying on the app to behave. Commands and config should require an encrypted, bonded link.
- Store bonds persistently and handle bond loss: when the phone forgets the device, the peripheral must allow re-pairing instead of refusing with stale keys forever.
- BLE link security does not authenticate the user. Application-level authorisation (signed tokens, challenge-response) is still needed for locks, payments and medical data.
Testing with bleak (Python)
import asyncio, struct
from bleak import BleakClient, BleakScanner
SAMPLE = "a1b20001-6f3c-4e8a-9d21-5c7e0b4f2a10" # random base; use your own
CONFIG = "a1b20002-6f3c-4e8a-9d21-5c7e0b4f2a10"
async def main():
dev = await BleakScanner.find_device_by_name("Sensor-01", timeout=10)
if dev is None:
raise SystemExit("not found: check advertising and that the device is not connected")
async with BleakClient(dev) as client:
print("mtu", client.mtu_size)
seqs = []
def on_sample(_, data: bytearray):
temp, rh, seq = struct.unpack("<hHI", data)
seqs.append(seq)
await client.start_notify(SAMPLE, on_sample)
await client.write_gatt_char(CONFIG, struct.pack("<BH", 1, 1), response=True)
await asyncio.sleep(30)
await client.stop_notify(SAMPLE)
gaps = [b - a for a, b in zip(seqs, seqs[1:]) if b - a != 1]
print(f"received {len(seqs)} samples, {len(gaps)} gaps")
asyncio.run(main())
bleak needs a Bluetooth adapter on the host and usually OS permission; it will not work in a sandbox without hardware. Give the user the script and ask for the output.
Other tools: nRF Connect (phone or desktop) for manual exploration; bluetoothctl and
btmon on Linux for HCI-level logs; Wireshark with an nRF sniffer for over-the-air traces
when a phone and device disagree about what happened.
Platform traps
- iOS caches the GATT table per bonded device. After changing services without the Service Changed indication, iOS keeps showing the old table until the user forgets the device.
- Android 12+ needs
BLUETOOTH_SCANandBLUETOOTH_CONNECTruntime permissions; older versions need location permission for scanning. - Android
gatt.writeCharacteristiccalls must be serialised: one outstanding GATT operation at a time, or later ones silently fail. - Many centrals limit simultaneous connections (often 4-8). Disconnect when done.
- Firmware updates over BLE (MCUmgr/SMP, Nordic DFU, ESP-IDF BLE OTA) belong to
firmware-ota.
Verification
- GATT table documented and matches what nRF Connect discovers on the device
- Automated script: subscribe, write config, 30+ s stream, zero sequence gaps
- Measured MTU and connection interval on each target phone, written down
- Bonded reconnect works on iOS and Android after power cycling the device
- Unencrypted write to a protected characteristic is rejected with an ATT error
- Current in advertising and connected-idle measured with a power profiler
Report
**Stack:** NimBLE on ESP32-C3 (ESP-IDF 5.3); centrals iPhone 15 (iOS 18), Pixel 8 (Android 15)
**GATT:** Battery, Device Info, custom sensor service (4 characteristics, table in docs/ble.md)
**Throughput:** MTU 247 (Android) / 185 (iOS); 2M PHY; 38 kB/s sustained, 0 gaps in 10 min
**Security:** LESC numeric comparison, config/command require bonded encryption
**Power:** 11 uA advertising at 1 s, 24 uA connected idle with latency 4 What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "BLE Devices and GATT"
short_description: "GATT profiles phones actually reconnect to"
default_prompt: "$ble-devices Design the GATT services for our BLE temperature logger" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.