Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Latchkey

Every camera app is event-driven, so none of them can tell you about the event that did not happen. A webhook fires when motion is detected. Nothing fires when a person who leaves the house every weekday morning does not leave.

Latchkey learns each recurring pattern from Ring's event history, derives a time window with a deadline, and asks one decidable question when the deadline passes: did a matching event land inside it? If it did, the event is absorbed and never shown. If it did not, and the camera was demonstrably up, one card reaches a human.

On the demo household that ratio is:

183 events observed  ·  183 absorbed  ·  4 expectations due  ·  1 escalated

The escalation is not one of the 183. It is the arrival that never came. That is the whole product, and it is the one thing a camera feed structurally cannot produce.

A card reads:

Front Door · motion.human · weekdays 07:53–08:33 · seen 20 of last 20 weekdays
· nothing by 08:33 · last event 20:08 (12h 25m ago)

Every number on it is checkable. Opening the evidence drawer shows the event ids behind the support count, the exact window in epoch milliseconds, and the paths of the Ring documents that define each field the claim rests on — fetched live from Ring's own documentation server while you look at them. No card ever says "AI detected".


Run it

No Ring account. No token. No network. No install step.

node --version   # 22.18 or newer, for native TypeScript
npm start

Open http://127.0.0.1:8787. A 28-day household loads from fixtures, six expectations are learned, and the incident day replays in about eighteen seconds — one full day of synthetic Ring notifications, each one HMAC-signed and POSTed over real HTTP to the real webhook route. Watch 08:33 pass.

npm test         # 85 tests, node:test, no framework
npm run doctor   # check our assumptions against Ring's live documentation
npm run fixtures # regenerate the household from its seed

npm run doctor is the only command that needs a network connection. It reaches Ring's MCP knowledge server, which needs no credentials, and fails if any field this codebase parses has stopped being documented.


What was exercised against what

This project was built without a Ring Playground token. Obtaining one requires an interactive browser sign-in, and the build was unattended. Rather than pretend otherwise, here is the exact state of every endpoint.

Endpoint Shape taken from Exercised against fixtures Exercised against live Ring
GET /v1/devices?include=status,capabilities amazon_vision_api/device_discovery.md yes no
GET /v1/devices/{id}/status device_discovery/status.md yes no
GET /v1/history/devices/{id}/events amazon_vision_api/event_history.md yes, including pagination and filters no
POST /v1/devices/{id}/media/image/download amazon_vision_api/image_download.md yes, 303 and 416 no
POST .../media/streaming/whep/sessions amazon_vision_api/live_stream/whep.md yes, including HEVC rejection no
DELETE .../whep/sessions/{id} same yes no
Webhook POST /webhooks/ring amazon_vision_api/notifications.md yes — real HMAC over real HTTP no
Ring MCP knowledge server first-party, no credentials n/a yes, on every doctor run
Amazon Bedrock InvokeModel AWS SigV4 documentation no no — no AWS account

Two rows are worth reading twice.

The webhook row is not fixtures. Offline mode signs its synthetic notifications with a genuine HMAC-SHA256 over the exact bytes on the wire and POSTs them to the same route Ring would. The receiver verifies against the raw body before parsing. Forged and re-serialised payloads are rejected with 401. The one security-critical path in this project is never bypassed, not even offline.

The api.amazonvision.com rows have been reached, but never authenticated. Running the server with an invalid RING_ACCESS_TOKEN sends the real GET /v1/devices?include=status,capabilities over the network and Ring answers 401. That proves the host, the path and the transport are real and that this client is the code that talks to them. It proves nothing about the response bodies, which is why every cell in the live column still says no.

The MCP row is live, first-party Ring, from any machine. npm run doctor opens a real connection to Ring infrastructure with no account, no token and no device, and checks forty-five documented field names across seven documents against what this code parses.

When someone does sign in, this flips itself:

RING_ACCESS_TOKEN=... npm run verify:live

That replays the same assertions against api.amazonvision.com and writes a dated report into verification/. The table above is updated from that file, not from memory. Until then every "live" column says no.


How it works

src/ring/client.ts     Partner API client. Real URLs, headers, pagination,
                       redirect policy, JSON:API parsing, error mapping.
                       Takes a `transport`; defaults to global fetch.
src/ring/fixtures.ts   The offline wire. Speaks HTTP, reproduces the traps.
src/ring/subtype.ts    Recovers the motion subtype history does not return.
src/ring/whep.ts       SDP: H.265 injection, recvonly validation.
src/webhook.ts         HMAC verification over raw bytes; idempotency.
src/expect/learn.ts    Event stream → expectations. The claim.
src/expect/watch.ts    Deadlines. satisfied / missed / unverifiable.
src/decide.ts          Absorb or escalate, with the evidence bundle.
src/phrase.ts          Deterministic sentence; Bedrock may re-render it.
src/mcp.ts             Ring's documentation server, over JSON-RPC.
src/doctor.ts          Spec-drift canary.
src/app.ts             Boot, learn, arm, ingest, tick.
src/server.ts          node:http. Console, SSE, /webhooks/ring, WHEP, media.
web/index.html         One page.

Node 22, TypeScript run natively. Zero runtime dependencies — no framework, no HTTP library, no AWS SDK, no build step, no node_modules.

Offline mode is a transport swap, not a mode flag

const client = new RingClient({
  token: LIVE ? TOKEN : 'offline-fixture-transport',
  transport: LIVE ? undefined : createFixtureTransport({ now: () => clock.ms }),
});

That is the entire difference between the two modes, and it is the only place in the process that branches on it. undefined means global fetch. Request construction, pagination, redirect handling, error mapping and parsing are the same code either way, so a bug in that code is a bug in both.

The fixtures reproduce the traps, not the happy path

A fixture that only returns success proves nothing. src/ring/fixtures.ts reproduces, deliberately:

  • links.next on every history page including the last, and including empty ones — the documented infinite-loop trap;
  • links.next carrying only page[key], so a filtered query silently widens on page two unless the client re-applies its filter (it does);
  • history responses that withhold the motion subtype, which is why src/ring/subtype.ts exists;
  • 416 MEDIA_NOT_FOUND when no media exists at an instant;
  • 400 UNSUPPORTED_HEVC_SDP_OFFER from an HEVC-only camera given an H.264 offer;
  • 503 from a camera that is offline.

Everything in fixtures/ is synthesised from a seed by scripts/gen-fixtures.ts and carries a _provenance block saying so. Nothing was recorded from a Ring account. No real Ring response is reproduced anywhere in this repository.

The learner

Grouped by (device, event type, motion subtype). Three day granularities are tried — every day, weekdays/weekends, each specific weekday — because real households live at all three. Within a group the pooled minutes-past-midnight are split wherever there is a gap wider than 90 minutes, so the 08:15 departure and the 11:40 mail carrier on the same camera do not collapse into one bimodal blob whose median is 10:00, a time nothing ever happens.

A candidate must survive four rules: at least 4 supporting days, a hit rate of at least 0.8, a mode no wider than 135 minutes, and a fitted window no wider than 90 minutes. Windows are centred on the median and sized by the MAD, so one morning she left at 06:40 does not drag the claim with it.

The mode-width rule is the one that stops a busy camera. Bounding only the fitted window is not enough: on a camera that sees something every hour, the 90-minute split never fires and the whole day becomes one mode, so the per-day sample degenerates into "the earliest arrival of the day" — an order statistic far tighter than the traffic underneath it. The MAD then collapses and pure street noise is claimed as a routine. Bounding the mode is what catches that.

Support is then recounted inside the fitted window and both gates re-applied, because "seen 20 of last 20 weekdays" is a claim about the window a reader is looking at. Counted across the mode instead, twenty 08:00 departures and eight 09:30 ones read as 28 of 28 for a window that only ever held twenty.

Overlapping survivors are resolved in favour of the more general reading, and the 0.8 threshold does the discriminating: a weekday habit scores 5/7 = 0.71 as a daily one and is rejected, while a genuinely daily habit absorbs its own weekday and per-weekday readings instead of shattering into seven patterns.

The watcher has three answers, not two

satisfied Something matched. An arrival before the window but inside a bounded grace lookback counts, flagged early — she left at 07:10, which is not an emergency, and firing on it would burn the one alert this product is allowed to send.
missed The window closed empty and the camera was up the whole time. The only outcome that reaches a human.
unverifiable The camera was offline for any part of the window. We cannot distinguish "she did not leave" from "we were not watching", so we say so. A five-minute blind spot is enough to hide a departure.

A pattern that misses three days running is retired rather than escalated again. Three days of silence is a changed routine, not three emergencies, and repeating the same alarm daily is how a monitoring product teaches people to ignore it.

The demo day exercises all three: the front door departure is missed, the back door camera drops off the network across its own window and comes back unverifiable, and the mail carrier and dog walker are absorbed.

Time is an argument

There are no timers in the decision path. watcher.tick(nowMs) resolves every deadline that has passed, which is the same call a production loop would make on an interval. That is what lets a month of household routine and a fourteen-hour absence be replayed in eighteen seconds — and what makes the boundary cases in tests/watch.test.ts testable at all.

Tests

85, node:test, no framework, no mocking library. They are weighted towards the decision logic, because that is the part that is more than a camera feed. The adversarial cases are the point: an arrival one millisecond past the deadline, a late arrival that must not retroactively unfire an alert, a camera blind for five minutes of a thirty-minute window, a retried webhook, a routine that has moved rather than stopped, a re-serialised payload whose signature must fail. tests/e2e.test.ts runs the real server, replays a full day over real HTTP with real signatures, and asserts that exactly one thing reaches a human.


Amazon Bedrock

LATCHKEY_BEDROCK=1 lets Bedrock re-word an already-decided card into one plain sentence. The model renders, it never decides. The decision, the evidence and every number are produced by src/decide.ts before the call is made, and if the call is slow, unconfigured or wrong, the deterministic template is what ships. Offline mode is unaffected.

The scope is deliberately tiny and deliberately the wrong shape. The obvious thing to do with a model here is have it read the events and judge whether something is wrong — which would make every card unauditable. A caregiver cannot check a hunch, and neither can a judge.

SigV4 is signed by hand in src/aws/sigv4.ts (about forty lines of node:crypto) rather than pulling in an SDK for one POST. This path has never made a successful call — no AWS account was available. What is verified without one is the key-derivation chain, which matches the expected value AWS publishes in its own Signature Version 4 documentation; see tests/aws.test.ts. The request assembly and response parsing around it are unverified, and one known uncertainty is flagged in the code: path segments are encoded once rather than twice.


Product feedback

Required by the hackathon, and written properly in FRICTION_LOG.md — fourteen entries with reproductions, workarounds and suggested fixes. The summary:

Ring Partner API (event history, device discovery, image download, WHEP)

Used for. Everything. Event history is the learner's entire input; device discovery gates streaming and supplies codecs; image download corroborates a missed-event card; WHEP opens live view from that card.

Worked. The documentation is unusually complete — request and response examples for every endpoint, error codes with their exact strings, and working code in three languages. The links.next infinite-loop trap is documented, which most vendors would leave you to discover. The HEVC workaround ships with the JavaScript to implement it. JSON:API compound documents are consistent and easy to join.

Needs work. (1) Event history returns event_type but never the motion subtype, which exists only as a query filter — recovering it costs n+1 requests per device. (2) subType on webhooks is camelCase, sits beside attributes rather than inside it, and drops the motion. prefix the history filter requires: three inconsistencies in one field, all of which fail silently. (3) The documented links.next carries only page[key], so a filtered query appears to work on page one and widens on page two. (4) Device capabilities do not publish a power source, which is the attribute that decides whether a WHEP session lasts 30 seconds or 60. (5) 416 is the wrong status for "no media exists here", which is often a useful answer rather than an error.

Onboarding. Reading the documentation: excellent, maybe two hours to a confident mental model. Making a single request: impossible for this build. Every route to a token, Playground included, ends at an interactive browser sign-in. There is no device-code flow, no read-only sandbox key, and no archive of sample responses. The last of those would cost Ring nothing and would have removed most of the uncertainty in this repository.

Would I build with it again. Yes, for anything observational. It is read-only for cameras — no arm, no siren, no talk, no snapshot trigger — and that constraint is spread across the endpoint reference rather than stated once near the top, which cost a day of design work on ideas that were dead on arrival. Say it in the first paragraph and every partner designs better.

Ring Appstore MCP knowledge server

Used for. npm run doctor — checking our assumed field names against Ring's live specification — and the live doc citations in the console's evidence drawer.

Worked. It needs no credentials, which is the single most useful property any part of this platform has for an unattended build. Plain streamable HTTP, initializetools/listtools/call in about sixty lines with no SDK. ring___get_doc returns whole documents, not snippets. It caught a genuine error in my own assumptions on its first run.

Needs work. initialize advertises resources and prompts capabilities that return nothing useful. search_docs types max_results and min_confidence as strings when they are numbers, which a strict MCP client will reject. Search relevance is uneven — a webhook-signature query ranks a deployment architecture document above the notifications document that defines it.

Onboarding. Ten minutes with curl. The best onboarding experience in the stack, and it is barely mentioned in the developer documentation.

Would I build with it again. Yes, and this is the recommendation I would make loudest: link it from the first page. A credential-free documentation endpoint that an agent can query at runtime is a genuinely new thing and Ring is burying it.

Amazon Bedrock

Used for. Optional one-sentence rendering of an already-decided card.

Worked. Nothing — see above. No AWS account was available and the path has never run.

Needs work. The one piece of friction I can report honestly is documentation rather than product: the SigV4 specification says non-S3 services double-encode path segments, while the SDKs single-encode for bedrock-runtime. Bedrock model ids contain a colon, so the two disagree, and there is no authoritative statement of which is right for this service.

Would I build with it again. Unknown, and I am not going to pretend otherwise.

Node 22 native TypeScript, node:test, node:crypto, node:http

Used for. All of it. Zero runtime dependencies.

Worked. Type stripping meant no build step and no dist/. node:test is fast and its TAP output is readable. node:crypto covers HMAC, SigV4 and the CRC32/zlib needed to synthesise a PNG frame in the fixture transport.

Needs work. Type stripping rejects parameter properties (constructor(private x: T)) at import time rather than at type-check time, so a small refactor surfaces as a runtime crash in an unrelated module. node --test given a bare directory tries to import the directory and fails with MODULE_NOT_FOUND, pointing nowhere near the cause.

Would I build with it again. Yes, without hesitation, for anything this size.


What this is not

  • It is not a security system. It observes and infers; the Partner API is read-only for cameras and Latchkey cannot arm, disarm, sound, or speak.
  • It is not a medical or emergency service. A missed expectation means an expected event did not occur, and nothing more than that.
  • It does not do video analysis. Every claim on every card comes from event timestamps and event types.
  • It will not find a routine buried in a camera's own background traffic. Where arrivals never pause for 90 minutes there is no separable mode to fit, and the learner declines rather than guesses. A doorbell or a door camera has the quiet gaps; a camera pointed at a public street does not.
  • The households in fixtures/ are synthetic. No real person's routine, and no real Ring account, is represented anywhere in this repository.

Configuration

Variable Default Effect
PORT 8787 HTTP port
RING_ACCESS_TOKEN unset When set, every request goes to api.amazonvision.com. Unset, the fixture transport answers.
LATCHKEY_WEBHOOK_SECRET random per process Ring's HMAC signing key. Generated and never persisted when unset.
LATCHKEY_REPLAY_MS 18000 Wall-clock duration of the one-day replay.
LATCHKEY_BEDROCK unset 1 enables Bedrock phrasing. Requires AWS credentials in the environment.

No secret is committed to this repository, and none is written to disk at runtime.

Licence

MIT. See LICENSE.

About

A caregiver console that alerts on absence: the Ring events that should have happened and didn't. Runs fully offline on fixtures.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages