Riverview

Developer Docs
/api/v1 local demo
· Lab exploration

RIVERVIEW

Applied Agentic AI and computer vision for traffic flow analysis and predictive modelling

Agentic AIComputer VisionPRIVACY
Context

Climate change, falling river water levels, and population growth are reshaping how people share increasingly contested waterways. RIVERVIEW is a working, privacy-respecting computer vision prototype - fully local, end-to-end pipeline running of a €22.50 camera board - built to see that pressure clearly.

Approach

RIVERVIEW was built end-to-end through directed agentic development — firmware, detection, a privacy-preserving pipeline, a versioned API and MCP server, and a scheduled monitoring agent for quality validation and actionable insights. Every decision tested versus assumed, and proven cheaply - before anything expensive got built.

Learning

RIVERVIEW's hardest problems turned out to be video resolution limits and messy real-world data - the kind of constraint only a real deployment surfaces.

Custom Camera & Detection
ESP32-S3 BOARD, YOLOV8N ON COCO CLASSES, LLAVA
Privacy-preserved insights
Monitor, Review, Track
Developer API & MCP
Versioned, key-authed, agent-accessible
Agentic self-improvement loops
SCHEDULED MONITORING, QUALITY VALIDATION, CONTINUOUS IMPROVEMENT
Overview — How it fits together

Architecture

One camera, two local server processes, and a browser that talks to both of them directly. Everything here runs on one laptop.

DFR1154 camera feeds detect_boats.py, which hands boat sightings to pipeline.py; pipeline.py optionally calls a Vision + OCR step, then both write into detections.db and snapshots/. api.py (FastAPI, port 8001) reads and writes that same database and serves images to the browser. The frontend (Nitro, port 3000) serves only the page shell directly to the browser. The frontend and api.py never call each other -- the browser is the only thing that talks to both. DFR1154 camera ESP32-S3 · stock firmware serves JPEG on request detect_boats.py laptop process · YOLOv8n face/body blur before disk write pipeline.py shared analysis module — camera + uploads, same code Vision + OCR Gemini Flash or local Ollama + EasyOCR, independently api.py — FastAPI port 8001 · reads + writes DB serves /snapshots/* directly detections.db (SQLite, WAL) + snapshots/ one writer, one reader — no locking conflicts rows + full frames + boat crops, on disk Frontend port 3000 · TanStack Start page shell only Browser Monitor · Reviewer · Boats localhost, one machine GET /capture wifi, 2.4GHz on sighting cooldown 30s merges into that row writes row + image reads on every request fetch /api/*, /snapshots/* same machine GET / HTML shell frontend & api.py never call each other directly — the browser bridges them

The browser makes two independent local connections, not one proxied through the other: it loads the page shell from the Nitro server on :3000, then fetches every event, stat, and image directly from the FastAPI server on :8001. The two server processes never call each other — the only thing they share is the SQLite database and snapshots/ folder that detect_boats.py writes to. That's also why the backend has to accept browser requests from the dashboard's own local origin. The vision/OCR hop (amber, dashed) is the one optional, swappable step in the pipeline — everything else always runs.

Ports & processes

ProcessPortRole
api.py8001Data + images — the only writer/reader of the DB
frontend3000Page shell only
detect_boats.py—No server — runs continuously, writes rows + images

Running it — two terminals, one laptop

# Terminal 1 — backend
cd laptop/backend && source venv/bin/activate
python3 api.py

# Terminal 2 — frontend (already built)
cd laptop/frontend
npm run start

Then open http://localhost:3000/ — that's the dashboard. See the API quickstart below for the separate developer API on :8001.

API — Getting started

Quickstart

A versioned, key-authed read API over Riverview's sighting data, under /api/v1/* on the same FastAPI process the dashboard already runs (http://127.0.0.1:8001).

Local demo

This isn't reachable off your laptop, and there's no self-serve signup — see "Getting a key" below.

Where things run

Two separate local processes, two separate addresses — don't mix them up:

AddressWhat it is
Dashboardhttp://localhost:3000/The TanStack Start frontend — open this in a browser to see sightings
Developer APIhttp://127.0.0.1:8001/api/v1/*The FastAPI backend — call this from code, curl, or the MCP server

They're on the same machine but different ports and, notably, different hostnames by convention: the dashboard is always referred to as localhost:3000, never 127.0.0.1:3000, matching the project README. Use 127.0.0.1 for the API as shown throughout this page.

Getting a key

Keys live locally, one key,label pair per line. Add a line yourself to issue a new key — no restart needed, the API rereads the file on every request. Delete a line to revoke one. Generate a key:

python3 -c "import secrets; print(secrets.token_hex(16))"

Then add it as a line, e.g. YOUR_API_KEY,my-laptop. The examples on this page use YOUR_API_KEY — swap in your own.

Your first call

curl http://127.0.0.1:8001/api/v1/stats \
  -H "X-API-Key: YOUR_API_KEY"
{"total_events": 214, "boat_count": 58, "person_count": 12, "manual_count": 3, "boat_passage_count": 41}

Every /api/v1/* route needs that same X-API-Key header. Missing or wrong key → 401. No keys configured at all → 503 (the error message tells you to add one).

Endpoints

MethodPathReturns
GET/api/v1/eventsDetection events, newest first, filterable
GET/api/v1/events/{timestamp}One event by its exact timestamp
GET/api/v1/boatsDistinct boats grouped by identifying-marks signature
GET/api/v1/statsSummary counts (includes boat_passage_count)
GET/api/v1/passagesBoat sightings stitched into passages, paginated

Full parameter-by-parameter reference: API reference below, generated from the backend's OpenAPI spec. The interactive version (with "Try it out") is at http://127.0.0.1:8001/docs while the backend is running.

Pagination

limit (default 50, max 1000) and offset on /events, /boats, and /passages. Responses are bare JSON arrays — keep incrementing offset until a call returns fewer than limit results to know you've reached the end.

Errors

{"detail": "Missing or invalid X-API-Key header."}
401
Bad or missing key
404
/events/{timestamp} with no matching event
503
No keys configured yet
422
A query parameter failed validation — the body explains which one
Read-only on purpose

There's no way to edit or delete a detection, or trigger an image upload, through /api/v1. Those actions stay on the dashboard's own local-only routes and aren't part of the developer API — don't build against them.

API — Data model

Concepts

The generated reference tells you the shape of each response. This page explains what the data actually means, since that's what most confusion traces back to.

Events

An event is one processed camera frame (or one uploaded image). A frame with both a person and a boat in it is one event with two detections, not two — GET /api/v1/events groups rows this way, keyed by a shared timestamp.

Detections and their source

Every detection has a source: camera (the live ESP32 feed), upload (dropped into the dashboard's Upload tab), or manual (logged by a person, not YOLO-detected). All three run through the same pipeline — source just tells you how the image got in.

Boat analysis fields

When a boat is detected, it's optionally run through a vision-language model plus a dedicated OCR pass, producing:

boat_type
e.g. "pontoon boat", "fishing boat"
boat_color
Dominant hull color
people_visible
Count aboard
activity
e.g. "cruising", "docked", "fishing"
identifying_marks
Any legible text — name, registration/hull number, hailing port
ocr_text
An independent OCR reading of the same crop, for cross-checking against identifying_marks
notes
Free-text scene description

Any of these can be null — either analysis wasn't enabled for that capture, or the model genuinely found nothing to report.

Worth knowing

identifying_marks/ocr_text are the two fields most likely to contain something identifying (a boat's registration number) rather than purely descriptive — worth keeping in mind before republishing them further.

"Has marks" filtering

has_marks=true excludes more than empty values — vision models often answer with a full sentence ("No visible markings or text on the boat in the image provided") rather than a short "none," so the filter also excludes anything containing "no visible," plus a short exact-match list. It never counts a person-class detection as "having marks," even if that field happens to be populated on its row.

Boats — grouping repeat sightings

GET /api/v1/boats groups every boat detection with a real identifying_marks/ocr_text reading into one entry per distinct boat, by canonicalizing that text (lowercase, strip punctuation, collapse whitespace) into a signature. Crops too small to plausibly carry a reliable reading are excluded entirely rather than risking a false match. This mirrors the dashboard's own Track view, though it's a separate implementation kept close in behavior, not byte-identical.

Boat passages vs. raw detection counts

The camera pipeline logs a new boat-class detection roughly every 1-2 seconds while a vessel stays in frame, so a raw count of boat-class rows (get_stats()'s boat_count) substantially overcounts real boat traffic — one boat crossing frame can produce anywhere from a handful to dozens of rows.

GET /api/v1/passages and boat_passage_count on /api/v1/stats fix this by clustering boat detections into passages — one per real boat sighting — based on runs of closely-spaced timestamps whose bounding-box position steps in a consistent direction:

{
  "detection_ids": [611, 612, 613, 614],
  "start_timestamp": "2026-08-25T14:30:31",
  "end_timestamp": "2026-08-25T14:30:54",
  "event_count": 4,
  "direction": "rightward"
}

detection_ids are the raw rows stitched together; direction is rightward, leftward, or null for a single-detection passage. Two boats detected in the exact same frame are never merged into one passage, however close their positions — being in the same frame means they were seen simultaneously, so they can't be one boat sampled twice.

Which field to use

Use boat_passage_count//passages for anything about real boat traffic or congestion — boat_count is left as raw per-frame detections for backward compatibility, but it isn't a boat count in the way the name suggests.

Confidence

confidence is the YOLO detection confidence (0–1) for camera/upload detections. Manual captures don't have one — stored as null in the database but returned as 1.0 here, since a manual log is a deliberate, fully-confident human observation, not an absence of information.

API — Version history

Changelog

Breaking changes get a new /v2 prefix, living alongside /v1 rather than replacing it. Additive changes — a new optional field, a new endpoint — land in the current version without a bump.

v1 — 2026-08-24

Initial release of the versioned, key-authed developer API, as a separate surface from the dashboard's own internal routes, which stay local-only and unchanged.

  • GET /api/v1/events — filtered/searched/paginated detection events
  • GET /api/v1/events/{timestamp} — single event lookup
  • GET /api/v1/boats — boats grouped by identifying-marks signature (new — previously only available client-side in the dashboard's Track view)
  • GET /api/v1/stats — summary counts
  • X-API-Key header required on every /api/v1/* route, checked against your local key file

v1 — 2026-08-26 (additive)

Boat-passage stitching, fixing a monitor-agent finding that raw boat-class detection counts substantially overcount real boat traffic.

  • GET /api/v1/stats — new boat_passage_count field: boat sightings clustered into passages, alongside the unchanged boat_count
  • GET /api/v1/passages — new endpoint: the individual stitched passages behind boat_passage_count, paginated (new)
API — Reference

API reference

Every /api/v1 endpoint, generated from the backend's OpenAPI spec (v1.0.0) so it can't drift from the code. All routes need the X-API-Key header — see Quickstart.

Events

GET/api/v1/events

List detection events

Events grouped by processed frame, newest first. A single frame with both a person and a boat detected is one event with two detections, not two events. Supports the same filter/search parameters as the dashboard's own event feed.

Parameters
NameInTypeDescription
classquerystringall | boat | person | manual
default "all"
qquerystringCase-insensitive search across notes/marks/OCR text
default ""
has_marksquerybooleanOnly events with a real identifying_marks reading (excludes empty/none-visible/illegible answers)
default false
limitqueryinteger | nullPage size
default 50 · min 1, max 1000
offsetqueryinteger
default 0 · min 0
Responses
200
Successful Response — Event[]
422
Validation Error
401
Missing or invalid X-API-Key
Example
curl "http://127.0.0.1:8001/api/v1/events" \
  -H "X-API-Key: YOUR_API_KEY"

GET/api/v1/events/{timestamp}

Get one event by timestamp

The timestamp is the value returned in each event's `timestamp` field from GET /events (URL-encode the colons, e.g. 2026-08-21T10%3A05%3A00).

Parameters
NameInTypeDescription
timestamppathstring
required
Responses
200
Successful Response — Event
422
Validation Error
401
Missing or invalid X-API-Key
Example
curl "http://127.0.0.1:8001/api/v1/events/2026-08-21T10:05:00" \
  -H "X-API-Key: YOUR_API_KEY"

Boats & passages

GET/api/v1/boats

List distinct boats by identifying-marks signature

Groups every boat detection with a real, non-junk identifying-marks or OCR reading into one entry per distinct boat, most-recently-seen first -- a server-side port of the dashboard's Track/Boats view grouping (see queries.get_boats's docstring for how the signature is computed).

Parameters
NameInTypeDescription
limitqueryinteger | nullPage size
default 50 · min 1, max 1000
offsetqueryinteger
default 0 · min 0
Responses
200
Successful Response — Boat[]
422
Validation Error
401
Missing or invalid X-API-Key
Example
curl "http://127.0.0.1:8001/api/v1/boats" \
  -H "X-API-Key: YOUR_API_KEY"

GET/api/v1/passages

Boat sightings stitched into passages

Raw boat-class detections clustered into passages -- one per real boat sighting -- by grouping same-direction detections within a short time gap (see queries.stitch_boat_passages()). Fixes the substantial overcount in boat_count from a single boat logging a new row every 1-2 seconds while it's in frame. Most-recent-first.

Parameters
NameInTypeDescription
limitqueryinteger | nullPage size
default 50 · min 1, max 1000
offsetqueryinteger
default 0 · min 0
Responses
200
Successful Response — Passage[]
422
Validation Error
401
Missing or invalid X-API-Key
Example
curl "http://127.0.0.1:8001/api/v1/passages" \
  -H "X-API-Key: YOUR_API_KEY"

Stats

GET/api/v1/stats

Summary counts

Total events and per-class counts, excluding audit-only upload rows with no detection.

Responses
200
Successful Response — Stats
422
Validation Error
401
Missing or invalid X-API-Key
Example
curl "http://127.0.0.1:8001/api/v1/stats" \
  -H "X-API-Key: YOUR_API_KEY"

Findings

GET/api/v1/findings/last

Most recent finding's timestamp

How a fresh monitor-agent run (each scheduled firing starts a new session with no memory of the last one) finds out where the previous run left off, without depending on any one file.

Responses
200
Successful Response — LastFindingTimestamp
422
Validation Error
401
Missing or invalid X-API-Key
Example
curl "http://127.0.0.1:8001/api/v1/findings/last" \
  -H "X-API-Key: YOUR_API_KEY"

GET/api/v1/findings

List recorded findings

Every finding a monitor-agent run has recorded, newest first. Pass run_id to see just one run's findings.

Parameters
NameInTypeDescription
run_idquerystring | nullNarrow to one run's findings
limitqueryinteger | nullPage size
default 50 · min 1, max 1000
offsetqueryinteger
default 0 · min 0
Responses
200
Successful Response — Finding[]
422
Validation Error
401
Missing or invalid X-API-Key
Example
curl "http://127.0.0.1:8001/api/v1/findings" \
  -H "X-API-Key: YOUR_API_KEY"

POST/api/v1/findings

Record a monitor-agent finding

The one write route on /v1 -- see this module's docstring for why it's a deliberate, narrow exception to the otherwise read-only design. Inserts one row into the separate agent_findings table (never touches a detection row) and appends the same row to that run's mirrored markdown file, agent_findings/agent_findings_<run_id>.md. Always created with status="pending".

Request body

FindingCreate (JSON)

Responses
201
Successful Response — Finding
422
Validation Error
401
Missing or invalid X-API-Key
Example
curl -X POST "http://127.0.0.1:8001/api/v1/findings" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "run_id": "2026-08-25T14:00:00",
       "category": "traffic-pattern",
       "observation": "<observation>",
       "evidence": "<evidence>",
       "proposed_action": "<proposed_action>"
     }'
API — Reference

Models

Response and request shapes used by the endpoints above. Validation errors (422) use FastAPI's standard {"detail": [...]} shape.

Boat

FieldTypeReq.Description
signaturestringyesCanonicalized identifying-marks/OCR text used to group sightings
labelstringyes
sighting_countintegeryes
first_seenstringyes
last_seenstringyes
sightingsBoatSighting[]yes

BoatSighting

FieldTypeReq.Description
timestampstringyes
detection_idintegeryes
image_pathstring | null
crop_pathstring | null

Detection

FieldTypeReq.Description
idintegeryes
timestampstringyes
classstringyes
confidencenumberyes
image_pathstring | null
crop_pathstring | null
bboxnumber[] | null[x, y, w, h] normalized 0-1 to the frame
boat_typestring | null
boat_colorstring | null
people_visibleinteger | null
activitystring | null
identifying_marksstring | null
ocr_textstring | null
notesstring | null
sourcestring | nullcamera | upload | manual

Event

FieldTypeReq.Description
timestampstringyes
detectionsDetection[]yes

Finding

FieldTypeReq.Description
idintegeryes
run_idstringyes
timestampstringyesWhen this run generated the finding, not an event timestamp
categorystringyes
observationstring | null
evidencestring | null
proposed_actionstring | null
statusstringyespending | approved | dismissed -- always "pending" on creation

FindingCreate

FieldTypeReq.Description
run_idstringyesGroups every finding from one monitor-agent run -- reuse the same value for every record_finding call within a single run. An ISO-ish timestamp is the expected shape, e.g. "2026-08-25T14:00:00" (also names that run's mirrored markdown file).
categorystringyestraffic-pattern | data-quality | coverage-gap | false-positive
observationstringyesThe pattern noticed, in plain language
evidencestringyesSpecific event timestamps / boat signatures the finding is based on
proposed_actionstringyesThe concrete action being proposed, pending human approval

LastFindingTimestamp

FieldTypeReq.Description
timestampstring | nullyesThe most recent finding's timestamp across every past run, or null if no run has ever recorded one. A fresh monitor-agent session (no memory of prior runs) reads this to know where the last one left off.

Passage

FieldTypeReq.Description
detection_idsinteger[]yesRaw detections.id values stitched into this passage
start_timestampstringyes
end_timestampstringyes
event_countintegeryesHow many raw boat-class rows this passage merged
directionstring | nullrightward | leftward | null (single-detection passage -- no second point to compare)

Stats

FieldTypeReq.Description
total_eventsintegeryes
boat_countintegeryes
person_countintegeryes
manual_countintegeryes
boat_passage_countintegeryesReal boat-passage count from track-stitching -- boat_count above is raw per-frame boat detections, which substantially overcounts real traffic (one boat crossing frame can log a dozen-plus rows while it's in view). Use this field for traffic-mix/congestion analysis. See queries.stitch_boat_passages().
MCP — Server setup

MCP server setup

Lets Claude query sightings, boats, and stats conversationally instead of you writing API calls by hand — a thin local wrapper over the developer API above.

1. Backend running

cd laptop/backend
source venv/bin/activate
python3 api.py

Leave this running — the MCP server has nothing to query without it.

2. Install the MCP server

cd laptop/mcp_server
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

3. Sanity check

CVP_API_KEY=YOUR_API_KEY python3 server.py --list-tools
- list_events: List detection events (one per processed frame, newest first).
- get_event: Get one event by its exact timestamp (as returned by list_events),
- list_boats: List distinct boats, grouped by a canonicalized identifying-marks/
- get_stats: Summary counts: total events, and counts by class (boat/person/manual).
- list_boat_passages: List boat sightings stitched into passages -- one per real boat
- get_last_run: Most recent finding's timestamp across every past monitor-agent run.
- record_finding: Record one monitor-agent finding, pending human approval.

If this fails, the backend isn't reachable or the key doesn't match a line in your local key file — fix that before wiring up Claude Desktop.

4. Wire it into Claude Desktop / Claude Code

{
  "mcpServers": {
    "river-watcher": {
      "command": "/absolute/path/to/laptop/mcp_server/venv/bin/python3",
      "args": ["/absolute/path/to/laptop/mcp_server/server.py"],
      "env": {
        "CVP_API_BASE": "http://127.0.0.1:8001/api/v1",
        "CVP_API_KEY": "YOUR_API_KEY"
      }
    }
  }
}

Restart Claude Desktop (fully quit, not just close the window) — or start a new Claude Code session — after editing the config.

5. Worked example

Try asking

"What boats were seen this afternoon with any identifying marks?" — Claude should call list_events(has_marks=true, ...) or list_boats() and answer from the real data. If it says it has no way to check, the config didn't load — recheck step 4 and restart.

Seven tools. Five are read-only over detection data: list_events, get_event, list_boats, get_stats, list_boat_passages. Two back the Agentic Monitor: get_last_run (read) and record_finding — the one write, which only appends to the separate findings log (see Tools reference). There's deliberately no tool to edit, delete, or upload detections — those stay dashboard-only.

MCP — Tools reference

Tools reference

Assumes the server from Server setup is already wired up. Every tool here is a thin wrapper over one call to the developer API above — same data, same pagination, same field meanings (see Concepts for what a field actually means). All tools are read-only against real detection data, except record_finding — a narrow write path for the Agentic Monitor's own append-only findings log, explained below.

Sighting & stats tools

list_events

list_events(class_filter="all", q="", has_marks=False, limit=50, offset=0)

Detection events (one per processed camera frame or upload), newest first. Wraps GET /api/v1/events.

class_filter
"all" | "boat" | "person" | "manual"
q
Case-insensitive search across notes/identifying_marks/ocr_text
has_marks
Only events with a real identifying-marks reading
limit / offset
Pagination, max 1000 per page

get_event

get_event(timestamp: str)

One event by its exact timestamp (as returned by list_events), e.g. get_event("2026-08-21T10:05:00"). Wraps GET /api/v1/events/{timestamp}.

list_boats

list_boats(limit=50, offset=0)

Distinct boats, grouped by a canonicalized identifying-marks/OCR signature, most-recently-seen first. Wraps GET /api/v1/boats — see Concepts for how the signature is computed.

get_stats

get_stats()

Summary counts, including boat_passage_count (real boat sightings, track-stitched). Wraps GET /api/v1/stats.

{"total_events": 214, "boat_count": 58, "person_count": 12, "manual_count": 3, "boat_passage_count": 41}

Prefer boat_passage_count over boat_count for anything about real boat traffic — boat_count is raw per-frame detections.

list_boat_passages

list_boat_passages(limit=50, offset=0)

Boat sightings stitched into passages — one per real boat, not one per detected frame — most-recent first. Wraps GET /api/v1/passages.

{
  "detection_ids": [611, 612, 613, 614],
  "start_timestamp": "2026-08-25T14:30:31",
  "end_timestamp": "2026-08-25T14:30:54",
  "event_count": 4,
  "direction": "rightward"
}

Agentic Monitor tools (Phase 0)

Back the scheduled monitor agent — a fresh session each firing, with no memory of the last one. record_finding is the one write tool on this otherwise read-only server, deliberately narrow: it only ever appends a row to a separate agent_findings table and can never touch, edit, or delete a real detection row. A finding is always created status="pending" — a human approves or dismisses it on the Track page.

get_last_run

get_last_run()

The most recent finding's timestamp across every past run, or null if none has ever been recorded — how a fresh session knows where to pick up. Wraps GET /api/v1/findings/last.

{"timestamp": "2026-08-25T15:26:14"}

record_finding

record_finding(run_id, category, observation, evidence, proposed_action)

Records one finding from the current run, pending human approval. Wraps POST /api/v1/findings.

run_id
Same value for every call within one run — an ISO-ish timestamp, e.g. "2026-08-25T14:00:00"
category
"traffic-pattern" | "data-quality" | "coverage-gap" | "false-positive"
observation
The pattern noticed, in plain language
evidence
Specific timestamps/signatures backing it up — checkable, not taken on faith
proposed_action
The concrete action being proposed, pending approval