AWS Builder Center
Building a Physical Status Indicator for Kiro Using Agent Hooks and BLE

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:
Kiro Stack Buddy Image
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.

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 stateM5Stack display
IDLECharacter sways gently side to side
WORKINGCharacter walks across the screen
WAITINGCharacter looks left and right
DONECharacter bounces at center
BLE disconnectedBRIDGE 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.x

Architecture

architecture
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"
}
}
]
}
HookTriggerState sent to M5Stack
SessionStartSession opensidle
UserPromptSubmitUser sends a promptin_progress
StopAgent finishes respondingcompleted
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.server

Why 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
#!/bin/bash
# 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-XXXX

Why 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:
  1. Draw the new position first, then erase the old one — eliminates blank frames
  2. Only redraw status text on state change — avoids unnecessary LCD writes
  3. Erase only the exposed region (pixels the new image doesn't cover) — _clear_exposed_previous_kiro in firmware/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 regions

Tuning 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.md

Conclusion

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
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article