Contract-first REST on MCUs: OpenAPI spec → admin UI without writing a dashboard
I have been working on UIGen: a runtime that turns an OpenAPI spec into a full admin UI (sidebar nav, list/detail views, config forms, charts). No React codegen, no Retool-style manual wiring. Change the spec, the UI updates.
I built C++ board simulators (ESP32 DevKitC and STM32 NUCLEO-F411RE) to show what this looks like for firmware folks who expose HTTP on the device (or on a Pi gateway in front of it).
The setup (two UIs, one API)
C++ simulator (visual demo) UIGen admin UI (from openapi.yaml)
http://localhost:8080 (ESP32) http://localhost:4400
http://localhost:8081 (STM32) http://localhost:4401
| |
+----------- same REST API -------------+
- :8080 / :8081 — interactive board diagram, live GPIO/sensor cards, event log (hand-rolled HTML/JS for the demo)
- :4400 / :4401 — generated control panel: board status, pin CRUD, sensor list, telemetry table + line chart, config forms, blink/reset actions
Both hit the same endpoints: /api/v1/pins, /api/v1/sensors, /api/v1/readings, /api/v1/config, etc.
The device (or simulator) serves JSON + optionally GET /openapi.yaml. The admin UI runs on your laptop on the LAN, so you are not baking a full frontend into flash.
How the OpenAPI spec was written (important)
The openapi.yaml was not auto-generated from C++. There is no reflection in httplib/ESP-IDF to do that cleanly.
We used contract-first design (same idea as a shared header file for a wire protocol):
- Model device resources: board, pins, sensors, readings, config, actions
- Define JSON schemas that match what firmware will actually emit
- Write
openapi.yamlas the canonical contract - Implement C++ handlers to match (
Pin,Reading,BoardConfigstructs mirror the schemas) - Serve the same file at
GET /openapi.yaml
If you already have curl output or C struct definitions, you do not need to start from a blank YAML file. The repo includes an AI agent skill (generate-device-openapi) that walks through drafting openapi.yaml from route tables, Postman exports, sample JSON, or struct headers. Then a second skill (auto-annotate) writes .uigen/config.yaml (labels, charts, layout, ignore rules).
Pipeline:
generate-device-openapi → openapi.yaml
auto-annotate → .uigen/config.yaml
uigen serve openapi.yaml --proxy-base http://<device-ip>:8080
Skills live in the repo under SKILLS/ and are copied into each example at UI/.agents/skills/ for Cursor / Copilot-style assistants.
What UIGen actually generates from the spec
From standard REST patterns in OpenAPI:
| Your API | Generated UI |
|---|---|
GET /pins → array |
List + table |
GET /pins/{id}, PUT /pins/{id} |
Detail + edit form |
GET/PUT /config |
Settings form |
POST /actions/blink |
Action form with validated body |
GET /readings?sensor_id=&limit= |
List + line chart |
Annotations in .uigen/config.yaml (not in the spec itself) drive the polish:
x-uigen-charton the readings list response:xAxis: recorded_at,yAxis: value, serverquery.limit: 500, LTTB downsampling to ~120 points, sensor filter viax-uigen-refto the sensors resourcex-uigen-ref:sensor_idandpin_idshow human names instead of raw integersx-uigen-ignore: hide/health,/openapi.yaml, and the visual-only/api/v1/statesnapshot from the sidebar- Layout: sidebar app shell; centered forms for actions like “Blink LED”
Chart filters refetch the list endpoint with query params your firmware already supports (sensor_id, limit). Client-side time window presets (1m, 5m, 1h, etc.) trim the x-axis for dense telemetry without extra API work.
Why this vs RainMaker / ThingsBoard / Node-RED
- No platform lock-in - your REST API stays yours; UIGen is a UI layer
- Spec is the product contract - firmware, docs, and UI stay aligned
- Works offline on LAN -
uigen serveproxies tohttp://192.168.4.1(typical AP mode); no cloud account required for the demo
Tradeoff: you need a decent OpenAPI file. That is what the skill is for.
Try it
ESP32 DevKitC (examples/apps/cpp/esp32-simulator):
git clone https://github.com/darula-hpp/uigen.git
cd uigen/examples/apps/cpp/esp32-simulator
# Terminal 1: simulator (Docker or local build)
docker compose up --build
# → http://localhost:8080
# In another terminal — run UIGen from UI/ so .uigen/config.yaml is picked up
cd UI
npx @uigen-dev/cli@latest serve openapi.yaml --proxy-base http://localhost:8080
# → http://localhost:4400
STM32 NUCLEO-F411RE (examples/apps/cpp/stm32-nucleo-simulator):
cd uigen/examples/apps/cpp/stm32-nucleo-simulator
# Terminal 1: simulator
docker compose up --build
# → http://localhost:8081
# In another terminal
cd UI
npx @uigen-dev/cli@latest serve openapi.yaml --proxy-base http://localhost:8081 --port 4401
# → http://localhost:4401
Example paths in repo: openapi.yaml, UI/.uigen/config.yaml, C++ routes in include/api_routes.hpp.
Roadmap: same spec, phone app (React Native)
We are working on a React Native target for the same OpenAPI → UI pipeline.
Plain language: today UIGen renders a web admin panel in the browser. The RN target will render a native iOS/Android app from the same spec and config - pin toggles, config screens, telemetry charts talking to your device over WiFi on the bench or in the field.
Think “companion app for technicians” without maintaining a separate Swift/Kotlin codebase. One openapi.yaml, web console for desk work, mobile app for walk-up commissioning. Still early; the C++ simulators are the reference implementation for now.
Links:
- Repo: https://github.com/darula-hpp/uigen
- ESP32 example:
examples/apps/cpp/esp32-simulator/ - STM32 example:
examples/apps/cpp/stm32-nucleo-simulator/ - OpenAPI skill:
SKILLS/generate-device-openapi.md - Chart annotation docs: https://uigen-docs.vercel.app (spec annotations /
x-uigen-chart)
Happy building, Id appreciate feedback or suggestions