---
name: metro-ai-app-recipe
description: >-
  Stand up a complete, ready-to-run computer-vision analytics stack on Intel
  hardware with one Docker Compose command — point it at your video sources and
  an OpenVINO/ONNX model to get live annotated WebRTC video plus real-time
  detection dashboards and alerts (object detection, classification, counting,
  or zone/line-crossing) for any vertical, with no glue code. See "When to use
  this skill" for the full component list, trigger conditions, and boundaries.
license: Apache-2.0
compatibility: >-
  Requires Docker + Docker Compose v2, host with Intel CPU (and optionally
  Intel GPU/NPU with `video`/`render` groups), outbound network access to
  Docker Hub, ghcr.io, and github.com (for model + sample video downloads).
  Ports 80 and 443 (Nginx) plus 3478/udp (Coturn TURN) must be free on the
  host; WebRTC also publishes MediaMTX port 8189 (tcp+udp) for ICE, with
  signalling proxied via Nginx. Tested
  with the open-edge-platform Metro Vision AI App Recipe reference
  (v2026.2.0 image tags).
---

# Metro AI App Recipe — DLSPS + WebRTC + Mosquitto + Node-RED + Grafana + Nginx

Build an end-to-end `{{OBJECT}}`-analytics stack on Intel hardware in
`./{{STACK_DIR}}/` with Docker Compose. **Vertical-agnostic:** the same
seven-container topology serves any DL Streamer / OpenVINO CV pipeline — only the
model, class filter, alert rule, dashboard, and topics differ. Follows the
open-edge-platform
[Metro Vision AI App Recipe](https://github.com/open-edge-platform/edge-ai-suites/tree/main/metro-ai-suite/metro-vision-ai-app-recipe)
**MediaMTX + Coturn + WebRTC** path, streamlined (**no Prometheus/OTel**).
Scenescape is **off by default** (opt-in multi-camera analysis). Data-flow and
routing are detailed in **Reference architecture** below.

## When to use this skill

**Use when** building an object-detection, classification, object-counting, or
zone/line-crossing alerting pipeline for any vertical (see table below).
Optionally adds a Scenescape multi-camera path, or a lightweight demo/PoC single
app when no full stack is needed.

**Not for:** non-Intel or cloud-only deployments, Prometheus/OpenTelemetry
metrics stacks, or training/exporting models.

## Supported verticals & use-cases

| Vertical | Example use-cases (each = one invoking prompt) |
|---|---|
| Smart city / ITS | person/vehicle detection, ANPR, smart-parking, wrong-way |
| Retail | customer counting, queue-length, shelf out-of-stock, dwell-time |
| Industrial / logistics | surface-defect, PPE compliance, zone intrusion, forklift tracking |
| Healthcare / facilities | fall detection, hand-hygiene, occupancy, perimeter intrusion |
| Custom | any OpenVINO IR / ONNX detector + optional classifier |

The invoking prompt maps its vertical to concrete `{{OBJECT}}`,
`{{PIPELINE_NAME}}`, `{{DEFAULT_MODEL}}`, `{{DEFAULT_RULE}}`, `{{DASHBOARD_SLUG}}`
— nothing else changes.

## How to use this skill

1. Read this file end-to-end.
2. Ask **Question 0 (packaging)** first. If **demo/function/port** (single-app
   path), branch to [Demo/PoC mode](#demopoc-mode) + load
   [`references/DEMO_POC.md`](references/DEMO_POC.md), skip questions 1–7; else
   (**microservice**, the default full stack) continue.
3. Ask the 7 questions in ONE batched message (defaults in brackets); accept
   `go`/`defaults`/empty. Question 7 selects the **Scenescape** path.
4. Run parameter validation (below); refuse to proceed on any failure.
5. Load reference file(s) on demand — **not all up front** (per the *Reference
   files* table): Scenescape when `{{SCENESCAPE}}=yes`; PIPELINE for GPU/NPU,
   RTSP/`/dev/video`, or classifier; NODE_RED for `<`/`<=`/`>=` rules or a
   non-empty `{{CLASS_FILTER_IDS}}`.
6. Verify against completion criteria before declaring success (record
   throughput/latency vs `benchmark.md`); `validate_env.sh` is **step 0 of
   `install.sh`**.

## Reference files (load on demand)

| File | Load when authoring |
|---|---|
| [`references/PIPELINE.md`](references/PIPELINE.md) | DLSPS `config.json`, GPU/NPU variants (`_gpu`/`_npu` + `group_add`), input sources (file/RTSP/device), `gvaclassify` wiring, REST launcher, watchdog |
| [`references/PROXY_UI.md`](references/PROXY_UI.md) | `nginx.conf` proxy (WHEP/WHIP + WebRTC-TCP), Grafana iframe panels, dashboard provisioning, Mosquitto |
| [`references/NODE_RED.md`](references/NODE_RED.md) | `flows.json`, MQTT wildcard, `gva_meta` probe, alert flow |
| [`references/INSTALL.md`](references/INSTALL.md) | file layout, `.env`, `validate_env.sh` + rules, `install.sh`, `docker-compose.yml` volumes |
| [`references/TESTS.md`](references/TESTS.md) | `conftest.py`, `test_webrtc_stream.py`, assertion contracts |
| [`references/SCENESCAPE.md`](references/SCENESCAPE.md) | **`{{SCENESCAPE}}=yes` only** — multi-camera scene-fusion via `scenescape-setup` skill |
| [`references/DEMO_POC.md`](references/DEMO_POC.md) | **single-app packaging only** (`{{PACKAGING}}`∈`demo`/`function`/`port`) — lightweight single-app path (DL Streamer or OpenVINO); no full stack |

## Parameters (from invoking prompt)

| Param | Purpose |
|---|---|
| `{{PACKAGING}}` | `demo` \| `function` \| `microservice` \| `port` (default `microservice`). `demo`/`function`/`port` = single-app path ([DEMO_POC](references/DEMO_POC.md)); `microservice` = full stack (rows below are `microservice`-only) |
| `{{MODE}}` | Back-compat alias derived from `{{PACKAGING}}`: `demo` (single-app) \| `production` (= `microservice`) |
| `{{HW_TARGET}}` | Intel inference device: `CPU`\|`GPU`\|`NPU`\|`AUTO` (default `CPU`; `AUTO` = pick). Drives `sample_start.sh <cpu\|gpu\|npu>` + `_gpu`/`_npu` variants; no platform/generation naming |
| `{{OBJECT}}` | class label in dashboard/alerts (e.g. `person`, `vehicle`, `defect`, `fall`); any MQTT/Grafana-safe string |
| `{{STACK_DIR}}` | e.g. `person-detect-stack`, `ppe-compliance-stack`, `anpr-stack` |
| `{{DEFAULT_MODEL}}`, `{{OTHER_MODELS}}` | allowed model options; when `auto`, suggest from OpenVINO / Intel / Metro Analytics Catalog HF collections per a performance goal |
| `{{PIPELINE_NAME}}` | canonical DLSPS pipeline `name` (e.g. `yolov11s`); variants `<name>`/`_gpu`/`_npu`; topic `{{DETECTIONS_TOPIC_PREFIX}}_X/<name>` |
| `{{CLASSIFIER}}` | secondary model or `none`; if set, also `{{CLASSIFIER_URL}}` + `{{CLASSIFIER_XML}}` |
| `{{CLASS_FILTER_IDS}}` | JSON array of class IDs to keep (`[]`=all). Filtered in Node-RED |
| `{{DEFAULT_RULE}}` | e.g. `count>2 in 10s`; parses to `{{RULE_OP}}`∈`>`,`>=`,`<`,`<=`, `{{RULE_N}}`, `{{RULE_WINDOW_S}}` (see [NODE_RED](references/NODE_RED.md)) |
| `{{RULE_SCOPE}}` | `per-source` \| `aggregate` (default `per-source`) |
| `{{ALERT_TOPIC}}` | e.g. `alerts/{{OBJECT}}` |
| `{{DETECTIONS_TOPIC_PREFIX}}` | e.g. `object_detection` (per-source `_1`, `_2`, …) |
| `{{COUNT_TOPIC}}` | e.g. `stats/{{OBJECT}}_count` |
| `{{LABEL_RULE_NOTE}}` | model-specific classification note for Node-RED |
| `{{DASHBOARD_SLUG}}` | e.g. `smart-parking` |
| `{{NUM_SOURCES}}` | default `4` |
| `{{SCENESCAPE}}` | `yes` \| `no` (default `no`). `yes` = multi-camera path ([SCENESCAPE](references/SCENESCAPE.md)) |
| `{{SCENE_NAME}}` | (Scenescape only) scene name, e.g. `intersection-1` |
| `{{CAMERA_IDS}}` | (Scenescape only) unique IDs (no `/`), one per input stream, in input order |
| `{{TURN_USER}}`, `{{TURN_PASS}}` | Coturn / MediaMTX ICE credentials (default `turnuser` / a generated secret) |

## Questions (single batched prompt)

**Question 0 — Packaging** [`microservice`]: `demo`/`function`/`port`
(single-app: PoC, one-shot job, or migrate a pipeline) or `microservice` (full
stack, **default**). For `demo`/`function`/`port`, STOP → follow
[Demo/PoC mode](#demopoc-mode); skip questions 1–7 (`microservice` only).

1. Model [`{{DEFAULT_MODEL}}`] (also: `{{OTHER_MODELS}}`; or `auto` — give a
   performance goal, I'll suggest one from the OpenVINO / Intel / Metro Analytics
   Catalog HF collections via `model-download`)
2. Classifier [`{{CLASSIFIER}}`] (or `none`)
3. Target hardware [CPU] — Intel `CPU`/`GPU`/`NPU`/`AUTO` (I pick). Multi-vendor
   noted as *suggestions only*; no platform/generation naming.
4. Inputs [{{NUM_SOURCES}}× sample-video] (or RTSP URLs / `/dev/videoN` / local
   paths); sets `INPUT_TYPE`. RTSP/device are **continuous** → no sample-video
   download, no file:// watchdog (see [PIPELINE](references/PIPELINE.md)).
5. Node-RED rule [`{{DEFAULT_RULE}}`, `{{RULE_SCOPE}}`]
6. Alert channel [MQTT `{{ALERT_TOPIC}}`]
7. Scenescape multi-camera spatial analysis? [`{{SCENESCAPE}}`, default `no`]
   (if `yes`, also collect `{{SCENE_NAME}}` + one unique `{{CAMERA_IDS}}` per
   input stream → [`references/SCENESCAPE.md`](references/SCENESCAPE.md))

## Parameter validation (enforce BEFORE `install.sh` runs)

Ship `validate_env.sh` and call it as step 0 of `install.sh`; reject on any
failure. The script body and full **validation rules table** (`PACKAGING`/`MODE`,
`HOST_IP`, `NUM_SOURCES`, `HW_TARGET`/`DEVICE`, `PIPELINE_NAME`, topics, TURN
creds, inputs, Scenescape params, …) are in
[`references/INSTALL.md`](references/INSTALL.md); validate `{{PACKAGING}}` ∈
`demo`/`function`/`microservice`/`port` and `{{HW_TARGET}}` ∈ `CPU`/`GPU`/`NPU`/`AUTO`.

## Reference architecture

Single Compose network `app_network`. Nginx publishes 80/443; **Coturn also
publishes `3478/udp`** (WebRTC TURN). Nginx reverse-proxies DLSPS REST, Grafana,
Node-RED, and MediaMTX WHEP/WHIP signalling (ICE 8189) — full route map in
[`references/PROXY_UI.md`](references/PROXY_UI.md). Data:
DLSPS→MQTT→Mosquitto→Node-RED→Grafana; DLSPS→WHIP→MediaMTX (peer-id
`{{DETECTIONS_TOPIC_PREFIX}}_N`, ICE/TURN via Coturn); Grafana embeds
`<iframe src="/mediamtx/{{DETECTIONS_TOPIC_PREFIX}}_N/">`.

## Demo/PoC mode

When Question 0 selects `demo`/`function`/`port` (single-app packaging), **do not
build the full stack** — produce one lightweight app proving a model runs on
Intel HW (DL Streamer app or minimal OpenVINO script). Follow
[`references/DEMO_POC.md`](references/DEMO_POC.md) (load only on this branch);
production criteria (1–11) do **not** apply.

## Scenescape spatial-analysis path (optional, `{{SCENESCAPE}}=yes`)

When Question 7 selects Scenescape, **branch**: keep the DLSPS detection pipeline
but replace the MediaMTX/WebRTC + Node-RED + Grafana-MQTT tail with an Intel®
Scenescape multi-camera scene-fusion stack. **Do not re-implement by hand** —
delegate to `scenescape-setup`, passing `{{SCENE_NAME}}` + `{{CAMERA_IDS}}`.
Architecture, images, validation, criteria in
[`references/SCENESCAPE.md`](references/SCENESCAPE.md); load only on this branch.
Default (`{{SCENESCAPE}}=no`) path is unchanged.

## Images — pin to the latest available tag (never `:latest`)

Resolve each image to the **newest published stable tag on Docker Hub** (query
the repo's `tags` API with `ordering=last_updated`), pin it, and ignore
`*-weekly` pre-releases. **Grafana is the one exception:** keep it pinned to
`11.5.4` (the MQTT datasource plugin only works on that tag).

- `intel/dlstreamer-pipeline-server:2026.2.0-ubuntu24`
- `eclipse-mosquitto:2.1.2-alpine`
- `nodered/node-red:5.0.4`
- `nginx:1.31.3-alpine`
- `bluenviron/mediamtx:1.20.0` (WebRTC: WHIP in, WHEP out)
- `coturn/coturn:4.17.0` (ICE/TURN)
- `grafana/grafana:11.5.4` (**pinned — do not upgrade**) with `GF_INSTALL_PLUGINS="grafana-mqtt-datasource 1.3.3,yesoreyeram-infinity-datasource 3.11.1"` — a bad plugin version kills the container → Nginx 502
- `intel/dlstreamer:2026.2.0-ubuntu24` (one-shot in `install.sh`: model dl + INT8 quantize + TLS cert)

## Layout (flat)

Generate a flat `{{STACK_DIR}}/`: `README.md`, `docker-compose.yml`, `.env`,
`validate_env.sh`, `install.sh`, `sample_*.sh`/`update_dashboard.sh`, a `src/`
tree (per-service dirs), and `tests/`. Full annotated tree in
[`references/INSTALL.md`](references/INSTALL.md).

### `README.md` (required content)

The generated `README.md` MUST document, at minimum:

- **Architecture** — the data + video flow from *Reference architecture* above;
  include the ASCII diagram + seven containers.
- **Quick start** — `./install.sh` → `docker compose up -d` →
  `./sample_start.sh <cpu|gpu|npu>`, plus stop/status scripts.
- **Access URLs + credentials** — dashboard `https://<HOST_IP>/grafana/`
  (**Grafana login `admin`/`admin`**, change on first login), DLSPS REST
  `https://<HOST_IP>/api/pipelines`, WHEP
  `https://<HOST_IP>/mediamtx/{{DETECTIONS_TOPIC_PREFIX}}_N/`.
- **Configuration** — key `.env` values (`HOST_IP`, GIDs, TURN creds) + how to
  swap the video source for RTSP.

## Template variable substitution

Substitute every `{{VAR}}` with its concrete value BEFORE writing any file — a
literal `{{...}}` left in `nginx.conf`, `config.json`, `flows.json`, the
dashboard JSON, or a test is a syntax error.

## Execution guardrails

- Hard timeouts: model dl+INT8 300 s; video dl 120 s/file; `compose pull` 300 s;
  `compose up -d` 120 s + 180 s healthy; each pytest 60 s.
- Max 2 retries per step, then STOP and print last 30 log lines from the failing
  container. Never loop.
- Before `compose up`: `ss -ltn` shows `:80`/`:443` free, `ss -lun` shows
  `:3478` free (Coturn TURN).
- **Bypass host proxy for all localhost/LAN curl** — corporate proxies route
  `https://localhost/...` through an unreachable proxy (→ `Could not resolve
  host` / 502). Every curl in `sample_*.sh` MUST use `--noproxy '*'` + `--cacert
  src/nginx/ssl/server.crt` (generated by `install.sh`); tests set `NO_PROXY=*`
  in `conftest.py`.
- Test WebRTC signalling with the criterion-7 curl (expect `200`; stream exists
  only after `sample_start.sh`). Test MQTT via `docker run --rm --network
  <project>_app_network eclipse-mosquitto:2.1.2-alpine mosquitto_sub -h broker -t
  '#' -v` (tag `2.1.2-alpine` is an intentional pin — never `:latest`).
- pytest venv at `./.venv` inside stack dir (`python -m venv .venv`) — system
  pip is PEP-668 blocked; `/tmp` may be `noexec`.

## Optional external skills

If available, invoke; otherwise write files from the reference templates.
- `dlstreamer-coding-agent` — pipeline JSON (+ single-app DL Streamer app when `{{PACKAGING}}`∈`demo`/`function`/`port`)
- `dlsps-user` — DLSPS deploy/config/REST; default `production` path ([PIPELINE](references/PIPELINE.md))
- `model-download` — OMZ model IR
- `scenescape-setup` — **only when `{{SCENESCAPE}}=yes`** ([SCENESCAPE](references/SCENESCAPE.md))

## Reference implementation

The upstream
[`smart-parking/src/`](https://github.com/open-edge-platform/edge-ai-suites/tree/main/metro-ai-suite/metro-vision-ai-app-recipe/smart-parking/src)
recipe uses the same path — consult it for `config.json`, `mosquitto.conf`,
`nginx.conf`, `datasources.yml`, `dashboards.yml`, `flows.json` shapes; drop
`prometheus`/`otel-collector`/`metrics-manager`.

## Completion criteria (all must pass)

> When `{{SCENESCAPE}}=yes`, criteria 3–11 are **superseded** by the Scenescape
> criteria in [`references/SCENESCAPE.md`](references/SCENESCAPE.md); 1–2 still apply.

1. `./install.sh` succeeds: `.env` populated; INT8 model + optional classifier
   IR under `src/dlstreamer-pipeline-server/models/…`; videos downloaded; TLS
   cert with SAN.
2. `./validate_env.sh cpu` exits 0 with a valid `.env`;
   `HOST_IP=127.0.0.1 ./validate_env.sh cpu` exits non-zero.
3. `docker compose up -d` → all containers `running`/`healthy` (incl.
   `mediamtx-server`, `coturn`).
4. `curl --cacert src/nginx/ssl/server.crt https://localhost/api/pipelines/status` returns 3 variants.
5. `./sample_start.sh <cpu|gpu|npu>` launches `{{NUM_SOURCES}}` pipelines; none
   `QUEUED`, all `RUNNING`.
6. Detections arrive on
   `{{DETECTIONS_TOPIC_PREFIX}}_1..{{NUM_SOURCES}}/{{PIPELINE_NAME}}`
   (or `_gpu`/`_npu`) within 30 s.
7. `curl --cacert src/nginx/ssl/server.crt https://localhost/mediamtx/{{DETECTIONS_TOPIC_PREFIX}}_1/`
   returns 200 (WHEP HTML) once pipelines run; MediaMTX logs show the WHIP
   publisher connected per `peer-id`.
8. Node-RED publishes JSON `{{ALERT_TOPIC}}` and **scalar** `{{COUNT_TOPIC}}` /
   `stats/alert_active` / `stats/alert_total` per `{{RULE_SCOPE}}`;
   `mosquitto_sub -t '{{COUNT_TOPIC}}/#' -C 1` MUST parse as `int()` (JSON breaks
   Grafana plotting).
9. Grafana at `https://localhost/grafana` (admin/admin) shows live {{OBJECT}}
   counts + alert data and `{{NUM_SOURCES}}` `<iframe>` WebRTC panels
   (`GF_SECURITY_ALLOW_EMBEDDING=true`). MQTT datasource health
   (`/grafana/api/datasources/uid/mqtt_ds/health`) MUST return `"MQTT
   Connected"` — broker in **`jsonData.uri`** (`tcp://broker:1883`), not `url:`.
   Blank-panel / redirect-loop fixes: [`references/PROXY_UI.md`](references/PROXY_UI.md).
10. `pytest -q tests/` passes; `pytest --collect-only -q tests/ | tail -1`
    reports ≥ 9 tests collected (no empty stubs).
11. **Watchdog continuity** (file:// sources): after `video-length + 30 s`,
    `/api/pipelines/status` shows `{{NUM_SOURCES}}` `RUNNING` (`COMPLETED`
    history is fine), WebRTC re-establishes, MQTT still flowing
    (`>{{NUM_SOURCES}}` `RUNNING` = missing watchdog dedup guard).

## Final summary — surface the proof (don't just name files)

Graders see only your **final message** + tool *names*, not file contents. An
expectation counts as met only if you **state it and quote the one decisive
line** in your closing summary — walk every completion criterion plus the
proof-point checklist (topology, WebRTC, pinned tags, MQTT/class filter, no
literal `{{...}}`, certs, curl guards, watchdog, rule, classifier, Scenescape)
detailed in [`references/INSTALL.md`](references/INSTALL.md) →
*Final-summary proof points*. A claim with no quoted evidence is treated as unmet.
