Skip to main content
Glama
dud8
by dud8
README.md
# 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.

```bash
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.

```bash
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:

```bash
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

```ts
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](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,
`initialize` → `tools/list` → `tools/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](LICENSE).