
Building a Physical Status Indicator for Kiro Using Agent Hooks and BLE
Kiro knows when it's working, but there's no built-in way to see that away from the screen. Here's how I built a pixel-art status display on M5Stack using Agent Hooks, a local BLE bridge, and MicroPython.
Your IDE knows when it's thinking. This post shows how to make that visible on a physical device — a pixel-art character that walks, waits, and celebrates alongside your Kiro agent.
Here's what it looks like on the device:

The stack is: Kiro Agent Hook → curl → local Python Bridge (aiohttp + bleak) → BLE Nordic UART Service → MicroPython on M5Stack. No cloud, no Wi-Fi credentials on the device, no OpenAI API key. Every command below is copy-pasteable and came from a real build.
Full code: github.com/ssknsskn/kiro-stack-buddy
What you will build
- A
.kiro/hooks/config that fires on IDE lifecycle events and posts to localhost - A Python Bridge that receives HTTP POST and relays state over BLE
- MicroPython firmware on M5Stack Basic that receives JSON over Nordic UART Service and animates a pixel-art character
| Kiro state | M5Stack display |
|---|---|
| IDLE | Character sways gently side to side |
| WORKING | Character walks across the screen |
| WAITING | Character looks left and right |
| DONE | Character bounces at center |
| BLE disconnected | BRIDGE OFFLINE text |
See all state animations in the GitHub README .
Prerequisites
- Kiro IDE installed and running
- M5Stack Basic (ESP32-based, built-in 320×240 LCD)
- Python 3.10 or newer
- uv for dependency management
- MicroPython v1.25.0 flashed to the M5Stack — see flash instructions in the README
- mpremote for file transfer
Confirm your Python environment is ready:
1
2
uv run python --version
# Python 3.13.xArchitecture

The key insight: Kiro's Hook system runs a shell command on each IDE event. A single
curl call to localhost is enough to bridge the IDE to any local process. The Python Bridge handles the BLE complexity, so the Hook config itself stays trivial.Step 1: Capture IDE events with Kiro Agent Hooks
Kiro's Agent Hooks let you run a command when lifecycle events fire. Create
.kiro/hooks/buddy-state.json with three hooks — one for each event:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"version": "v1",
"hooks": [
{
"name": "Buddy: Session Start",
"trigger": "SessionStart",
"action": {
"type": "command",
"command": "curl -s -X POST http://127.0.0.1:9876/event -H \"Content-Type: application/json\" -d '{\"event\":\"session_start\"}' 2>/dev/null || true"
}
},
{
"name": "Buddy: Prompt Submit",
"trigger": "UserPromptSubmit",
"action": {
"type": "command",
"command": "curl -s -X POST http://127.0.0.1:9876/event -H \"Content-Type: application/json\" -d '{\"event\":\"prompt_submit\"}' 2>/dev/null || true"
}
},
{
"name": "Buddy: Stop",
"trigger": "Stop",
"action": {
"type": "command",
"command": "curl -s -X POST http://127.0.0.1:9876/event -H \"Content-Type: application/json\" -d '{\"event\":\"stop\"}' 2>/dev/null || true"
}
}
]
}| Hook | Trigger | State sent to M5Stack |
|---|---|---|
SessionStart | Session opens | idle |
UserPromptSubmit | User sends a prompt | in_progress |
Stop | Agent finishes responding | completed |
The
2>/dev/null || true at the end is important: if the Bridge isn't running, the curl fails silently and Kiro continues without interruption. The Hook doesn't know or care whether the Bridge is up.Step 2: Build the local Bridge (HTTP → BLE)
The Bridge has one job: accept HTTP POSTs from the Hooks and relay them over BLE to the M5Stack.
The HTTP handler (
handle_event in bridge/server.py) receives a JSON POST from the Hook, maps the event type to a device state, and calls send_state() to relay it over BLE. aiohttp runs the server; bleak manages the BLE client.On startup, the Bridge scans for a BLE device advertising under the name
KiroBuddy and connects. If the M5Stack is power-cycled, the disconnected_callback fires and the Bridge automatically rescans and reconnects — you never have to restart the Bridge manually.Sending state over BLE
Nordic UART Service (NUS) is a well-known BLE profile that emulates serial TX/RX over two Characteristics. Using it means the UUIDs are standardized and well-supported by both
bleak (Python) and MicroPython's bluetooth module.The payload is JSON terminated with a newline, chunked to 20 bytes to fit BLE's default MTU. This is
send_to_device in bridge/server.py:1
2
3
4
5
6
7
8
9
async def send_to_device(self, data: dict):
payload = (json.dumps(data, separators=(",", ":")) + "\n").encode("utf-8")
# Split into 20-byte chunks to fit BLE default MTU
for index in range(0, len(payload), 20):
await self.client.write_gatt_char(
NUS_RX_UUID,
payload[index : index + 20],
response=False,
)The JSON schema sent to the device looks like this:
1
2
3
4
5
6
7
8
{
"v": 1,
"type": "state",
"state": "in_progress",
"message": "Working",
"sequence": 3,
"timestamp": 1720000000
}The
sequence field lets the firmware discard out-of-order or duplicate messages — useful when reconnections happen mid-stream.Start the Bridge with:
1
uv run python -m bridge.serverWhy BLE instead of Wi-Fi
M5Stack projects often use Wi-Fi. Here's why I went with BLE for this use case:
- No SSID/password on the device. BLE pairs by name —
KiroBuddy— and that's it. - No network join required. The M5Stack never touches your LAN, which keeps things simpler from a security standpoint.
- The payload is tiny. State updates are a few dozen bytes. BLE handles this easily.
- It's local by design. Mac to M5Stack is a few meters. BLE is the natural fit.
I had used M5Stack over Wi-Fi before, but wiring up small state updates over BLE turned out to be much less ceremony than setting up an MQTT broker or HTTP server on the device.
Step 3: Flash MicroPython and deploy firmware
Flash MicroPython
MicroPython v1.25.0 needs to be flashed to the M5Stack before deploying the firmware. Full instructions using esptool are in the README .
Deploy firmware files
The deploy script (
scripts/deploy.sh) transfers all necessary files and resets the device in one command:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# scripts/deploy.sh (excerpt)
PORT="$1"
echo "[1/3] Transferring LCD driver..."
uv run mpremote connect "$PORT" cp firmware/ili9342c.py :ili9342c.py
echo "[2/3] Transferring pixel art assets..."
uv run mpremote connect "$PORT" cp firmware/assets/kiro_bw_96x96.bmp :/kiro_bw_96x96.bmp
uv run mpremote connect "$PORT" cp firmware/assets/kiro_bw_96x96_left.bmp :/kiro_bw_96x96_left.bmp
echo "[3/3] Transferring main.py..."
uv run mpremote connect "$PORT" cp firmware/main.py :main.py
echo "Resetting..."
uv run mpremote connect "$PORT" reset
echo "✓ Deploy complete"1
./scripts/deploy.sh /dev/cu.usbserial-XXXXWhy this matters for AI-assisted development
The biggest advantage of a CLI-based deploy workflow is that Kiro (the AI agent) can run the deploy command directly. During development, my prompts to Kiro looked like this:
- "Speed up the WORKING animation in firmware/main.py"
- "Run a syntax check then deploy to the M5Stack"
- "The display is flickering — find the cause and fix it"
Kiro would edit the code, call
deploy.sh, and the result appeared on the physical device seconds later. With GUI tools like Thonny or UIFlow, every iteration requires manual interaction that an AI agent can't perform. The CLI loop — edit → deploy → observe → repeat — is what made AI-assisted embedded development practical here.Step 4: Receive BLE on the M5Stack side
On the MicroPython side, two design decisions kept the animation smooth.
Initialize BLE before loading images
ESP32 needs a large contiguous memory block to initialize BLE. Loading the BMP images first causes heap fragmentation that can crash the BLE init. Here's the initialization order in
firmware/main.py:1
2
3
4
5
6
7
8
9
10
# Wrong order — BLE init crashes after loading ~36KB of BMP data
self.bmp_right = self._load_bmp("/kiro_bw_96x96.bmp")
self.bmp_left = self._load_bmp("/kiro_bw_96x96_left.bmp")
self._init_ble() # ← MemoryError here
# Correct order
self._init_ble()
gc.collect()
self.bmp_right = self._load_bmp("/kiro_bw_96x96.bmp")
self.bmp_left = self._load_bmp("/kiro_bw_96x96_left.bmp")Always initialize BLE first on ESP32 MicroPython, then load large buffers.
Lessons learned
The display flicker problem
The initial implementation cleared the entire screen black before each redraw — causing heavy flicker. Three incremental fixes solved it:
- Draw the new position first, then erase the old one — eliminates blank frames
- Only redraw status text on state change — avoids unnecessary LCD writes
- Erase only the exposed region (pixels the new image doesn't cover) —
_clear_exposed_previous_kiroinfirmware/main.py:
1
2
3
4
5
6
7
8
def _clear_exposed_previous_kiro(self):
# Compute the overlap between old and new bounding boxes
overlap_left = max(old_left, new_left)
overlap_top = max(old_top, new_top)
overlap_right = min(old_right, new_right)
overlap_bottom = min(old_bottom, new_bottom)
# Fill only the exposed strips with black
# ... fill_rect(BLACK) on non-overlapping regionsTuning animation speed
The WORKING update interval went through 50ms → 60ms → 70ms. The right value isn't derivable from specs — it requires watching the actual device. This is exactly where the CLI deploy loop paid off: I could tell Kiro "a bit slower" and have the change on hardware in under 30 seconds.
Project structure
1
2
3
4
5
6
7
8
9
10
11
12
13
kiroStack/
├── .kiro/hooks/buddy-state.json # Kiro Agent Hook config
├── bridge/server.py # HTTP + BLE Bridge (aiohttp + bleak)
├── firmware/
│ ├── main.py # M5Stack firmware (MicroPython)
│ ├── ili9342c.py # LCD SPI driver
│ └── assets/ # RGB565 BMP files (right + left)
├── scripts/
│ ├── deploy.sh # mpremote deploy automation
│ ├── prepare_assets.py # PNG → RGB565 BMP conversion
│ └── render_*_demo.py # GIF generation for README
├── pyproject.toml # uv / Python 3.13
└── README.mdConclusion
Wiring Kiro to a physical device turned out to be three small pieces:
- Kiro Agent Hooks — one JSON file, three curl commands
- Local Python Bridge — aiohttp receives HTTP, bleak sends BLE
- MicroPython on M5Stack — NUS receive, IRQ buffer, main-loop JSON parse
No cloud infrastructure, no Wi-Fi credentials on the device, no SDK. The full bridge from IDE event to pixel animation is under 300 lines of Python and MicroPython combined.
The CLI-first approach —
mpremote for deploy, shell scripts for automation — turned out to be the key ingredient for AI-assisted embedded development. An agent can run deploy.sh, observe the result, and iterate. That loop doesn't work with GUI tools.Appendix: References
Kiro
MicroPython / M5Stack
BLE
Code
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article