Skip to content

Case study · Argus

What I built on top of two open-source projects

Argus started as a fork of an OSINT map dashboard, and I merged a forecasting engine into it from a second project. This is a tour of the features I added, which are mostly about cameras: making thousands of them fast, live, and reliable enough to actually use.

  • Next.js 16 · React 19
  • SQLite · better-sqlite3
  • MapLibre GL · deck.gl
  • MCP · Docker
resolve strategies behind one camera contract
3resolve strategies behind one camera contract
DOT networks wired through the video registry
8DOT networks wired through the video registry
spatial index serving every map query
R-treespatial index serving every map query
NYC cameras, polling at the source's cadence
~960NYC cameras, polling at the source's cadence

Feature

A camera store that survives its sources

The camera layer pulls from state Department of Transportation feeds. Thousands of cameras across dozens of regional APIs, and those APIs are uneven: rate-limiting WAFs, timeouts, whole regions unreachable for stretches at a time.

Camera metadata does not actually change. A camera's position, name, roadway and milepost are the same today as yesterday. So I stopped fetching it live and put it in SQLite, where a map query is a spatial index lookup instead of a network call:

CREATE VIRTUAL TABLE cctv_rtree USING rtree(
  id, minX, maxX, minY, maxY
);

-- panning the map is an index lookup, not a table scan,
-- and it never touches the network
SELECT i.json FROM cctv_rtree r
  JOIN cctv_index i ON i.id = r.id
 WHERE r.maxX >= ? AND r.minX <= ?
   AND r.maxY >= ? AND r.minY <= ?
 LIMIT ?;
sql · cctvDb.ts: an R-tree virtual table, joined against the camera rows

It runs in WAL mode so readers never block on the writer, and the index rebuild is transactional, so the spatial index can never disagree with the camera rows it points at. Refresh happens in the background on a long interval, and a stale region is served immediately while it revalidates.

The part I like most is the failure behavior. The whole SQLite layer is wrapped so that if the native module will not load or the file will not open, every call degrades to a no-op and the app falls back to its in-memory cache. A cache that can take down the thing it is caching is not a cache, it is a liability.

Feature

Live video, three ways

These are public feeds: the same 511 traffic cameras every state publishes on its own website. Most ship still snapshots. A few also offer live video, but every state exposes it differently, and the mechanism is usually undocumented, so the same public stream takes a different shape in each place.

Rather than special-case each one, I described the shapes. The result is a registry where a state declares how its video is reached, and the app resolves the stream through one of three strategies behind a single camera contract:

export type CarsVideoMode = 'direct' | 'getVideoUrl' | 'twostep';

// direct      the HLS manifest sits at a predictable URL
// getVideoUrl an endpoint hands you the URL when you ask for it
// twostep     fetch a short-lived token, then exchange it for a stream
typescript · carsVideo.ts: the mode a state declares, and how its stream gets resolved

Adding a new state to the registry is a config entry rather than a code path, which is the whole point. Eight networks are wired through it, and another dozen or so emit live video through their own fetchers.

New York City was the interesting exception. It has no live video at all, just snapshots, and the source only produces a new frame every couple of seconds. Polling faster than that re-fetches identical bytes. So rather than hammer it, I added a per-camera refresh hint to the camera contract and let those cameras declare their own honest cadence. About nine hundred and sixty of them, polling at the rate the source actually updates.

Feature

Metadata that admits where it came from

A camera is much more useful when you know it is on I-4 westbound at milepost 78 than when you only know its latitude. Some sources give you that. Most bury it in a display name, and some do not have it at all.

So there is a standard: roadway, direction, milepost, resolved native-first and parsed out of the name as a fallback. And each field records metaSource, so I can always tell whether a milepost came from the source or from my own regex.

That last part matters more than it sounds. Parsed metadata is a guess. If you store a guess next to a fact without labeling which is which, you have quietly downgraded the fact, and six months later nobody can tell you which fields to trust.

Feature

A deliberately light-touch crawler

Filling in that metadata means fetching a lot of pages from the state 511 systems. They are someone else's servers, and they push back on bursts, so the considerate move is to go slow. The instinct is to parallelize hard. The instinct is wrong, and the measurement is sitting in the code:

// Fire remaining pages at LOW concurrency — these CARS WAFs 500 a rapid burst (at
// concurrency 8 ~15% of pages fail; at 3, none). And tolerate a stray page failure
await pool(pages, 3);
typescript · carsListMeta.ts: the cap is an empirical result, not a guess

Eight-way is faster in a benchmark and slower in reality: a burst trips their rate limiting, a fifteen percent failure rate means retries, and retrying against a server you have already leaned on produces more failures. Three parallel requests is slower per page, finishes sooner, and keeps my footprint on their infrastructure small.

The crawler also tolerates partial failure instead of discarding a whole region because some pages did not come back, caches for a week because milepost markers do not move, and hydrates from SQLite on cold start so a restart never triggers a full re-crawl. Static data, fetched once, gently.

Feature

Merging in the forecasting engine

Pythia is a Python forecasting service plus an overlay of data routes and UI. It ships as an install kit: copy these files into your OSIRIS checkout. Following that exactly would have worked and produced something incoherent, because Pythia has its own floating-window UI and Argus already had a tiling grid workspace.

So I took the content and left the chrome. The tool panels got ported into my existing grid, and the forecast deck got grafted into my own panel system rather than importing a second, competing window manager. Two window paradigms in one app is not a merge, it is a mess with extra steps.

The engine is Pythia's Python service, and its proxy route came across with it. What I added was the deployment story: Pythia ships no container at all, so I wrote the Dockerfile and stood the engine up in Compose alongside a local model runner, which is what lets the whole thing come up with one command instead of a shell script and a prayer.

Feature

Satellites at altitude, eventually

I wanted satellites to render above the globe at something like their real altitude, so the map reads as a three-dimensional scene rather than dots on a flat plane.

I tried this several ways in MapLibre and it does not work. You can raise objects visually, but you cannot keep them interactive up there, and a satellite you cannot click is a decoration. I reverted the whole approach and landed it with a deck.gl overlay on top of the existing map instead, which gives GPU picking that survives the zoom transition.

Feature

A workspace you can name and come back to

A saved view is not a bookmark. It captures the whole workspace as one blob: which feed panels are open, where the map is pointed, and which layers are live. Restore it with a click, and it persists locally so it survives a refresh. The one I actually use is the cameras around the I-4 on-ramp I take most mornings, so I can glance at the merge before I leave the house.

The part worth pointing at is the storage format. Saved views are versioned, with a fallback that reads the old unversioned shape and rewrites it in the current one, so changing the schema never orphans what someone already saved. It is a small feature that treats the data living on someone's machine as something worth not breaking.

Feature

The interface around it

The workspace is a tiling grid that floats over the map, built so the container itself is click-through and only the tiles capture input, which means panels never steal a drag you meant for the globe. It measures its own width rather than trusting the layout library, because it lives inside a fixed overlay and the library gets it wrong.

The status strips along the bottom got consolidated into a single ticker that cycles between markets, headlines, and live status. The scroll used to stutter, because a background refresh mid-animation would change the content width and shift the target. Keying the animation on a content signature makes a refresh restart it cleanly instead of jumping.

Feature

An MCP server, so I can ask Claude to look

The same public feeds are also exposed as an MCP server, which turns Argus into a set of tools an assistant like Claude can call. The tools are task-shaped rather than a thin wrapper over the API. geo_snapshot takes a place name, geocodes it, and returns everything Argus knows in that area, cameras and alerts and hazards, correlated into one result. camera_snapshot pulls the current frame of a camera and hands it back as an image the model can actually look at.

That closes a loop I use for real. I can ask Claude how my on-ramp is looking, and it geocodes the intersection, finds the nearby cameras, fetches the frames, and tells me whether the merge is backed up, because it looked rather than guessed.

That gap is the whole point, and it is the most useful thing on this page. An endpoint answers a request. A tool shaped for an agent answers a question. Designing for the second is the same instinct that runs through Clara: give the model a surface it can reason with, not just an API it can call.