# OpenVibe.Watch > Persistent, user-defined observations and conditions over pages, feeds, APIs, webhooks and OpenVibe events: define what to > watch, get told when it changes or fires — cheaply, because a Watch never polls when an event or > the site's own ETag will do. **Live site:** https://openvibe.watch · **API docs:** https://openvibe.services · **For AI:** https://openvibe.watch/llms.txt **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` **Status:** alpha (plan T18). The registry, the check engine, the pull carriers (`http`, `feed`, `api`), the push sources (OpenVibe events and signed webhooks), Link probes from the owner's own machine, the actions (a notification, an event, a webhook, an Actor task or agent run, a Run job, a Codes task) and the public site (sign-in, seven starting points, forms) exist. The Run rung (browser or code polling through OpenVibe.Run) and the `ai` extraction do not yet. **Domain:** `openvibe.watch` (a public, server-rendered site at [Public site](#public-site); the API is loopback-only). Install steps: [Public host](#public-host). **Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 — T18 (Watch complete), the carrier preference order, "a Watch has source, cadence or event source, extraction, comparison, condition, action, budget, retention". **License:** AGPL-3.0. ## Purpose Sources ingests the sources a product chose; Watch is the place a **person or an agent** defines their own observation: "this page's price", "this feed's latest release", "this API's stock", and the condition on it. Sources performs permitted structured ingestion; **Watch owns user-defined persistent observations and conditions**; Run executes expensive browser/code checks; Link observes local/LAN things; Actor interprets and acts; Events transports the trigger. A Watch is a first-class OpenVibe resource (`watch.watch@1`), so products compose it instead of each growing a watcher: Rent, Homes, Work, Deals, Reviews, price trackers, uptime, releases. ## Running it ```bash npm install cp .env.example .env npm run dev # http://127.0.0.1:4730 npm test # every test/*.test.js against local stub sites; no internet npm run test:pg # the same through PostgreSQL + PgBouncer (the CI path) ``` Node 22 in production. Production: `/opt/openvibe.watch`, env `/etc/openvibe/watch.env`, unit [deploy/systemd/openvibe-watch.service](deploy/systemd/openvibe-watch.service), database `ov_watch` on the host's data role (ADR-035; `sudo /opt/openvibe.host/roles/data/add-service.sh watch`), nginx [deploy/nginx/openvibe.watch.conf](deploy/nginx/openvibe.watch.conf). `GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 503 only when the database fails; a Network key that has not loaded and a check worker that is off, stopped or behind (a watch due for more than 15 minutes) degrade it. It reports watch counts by health status, checks in flight, observations by state and the outbox backlog. `GET /metrics` (openvibe-shared/metrics) answers direct loopback callers only: golden signals by route template, `watch_watches{status}`, `watch_observations{state}`, the check queue (`watch_checks_due`, `watch_checks_oldest_wait_seconds`, `watch_checks_in_flight`), `watch_last_check_timestamp_seconds`, `watch_last_success_timestamp_seconds`. Request telemetry: every HTTP request is aggregated by route template, method and status class, never by raw URL. Once a minute one `http.request` sample per key, plus the active-requests, p95 latency and event-loop-lag gauges, goes to this service's own `telemetry_samples` table (openvibe-sdk 0.46, `platform.telemetry-sample@1`, migration 0011); rows older than 14 days are deleted daily. `/shared` files, static assets, health, readiness and `/metrics` are not counted. `server/app.js` mounts openvibe-sdk's `telemetryMiddleware` (a no-op until init); `server/index.js` calls `telemetry.init()` at production start, runs the retention and flushes on stop. ## Public site `openvibe.watch` is also the place a person manages their own watches in a browser. Every page is server-rendered and complete without JavaScript: the create and edit forms POST, pause/resume/ delete/check-now are plain forms, and the fields a form shows are chosen by the query (`/watches/new?template=price`), not by script. The visual layer is plain semantic HTML on the classes openvibe-shared's frame and this repository's `public/css/app.css` already provide. **Sign-in** is OpenVibe.Network's OAuth 2 authorization code with PKCE (S256), as the OAuth client `watch` (`OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET`), redirecting to `BASE_URL/auth/callback`. The Network token is verified once (issuer, audience `openvibe.network`, a `usr_…`/`agt_…` subject) and then discarded; the browser holds an opaque random session token in an httpOnly, SameSite=Lax cookie, and Watch's database stores only its SHA-256 hash (`web_sessions`, migration 0002). The signed-in person's `usr_…` subject is the watch owner: the site calls the **registry directly, in-process**, with exactly the ownership rules the API applies — another person's watch is 404 on every page and every form, never 403. A signed-in write must also come from this site itself (same-origin), so another site cannot make a visitor act here. | Page | What it shows | |---|---| | `/` | what Watch is in one paragraph; the seven starting points as cards; channels active on OpenVibe.Live lately (live first) with a "tell me when they go live" watch each; ready-made watches (Host plan prices, release and job feeds, each blog's new posts); how it checks in plain words; the API samples; the questions (FAQPage JSON-LD); your watches summary (counts by status) when signed in. The channels, plans and blogs are OpenVibe.Search's public documents, read anonymously and kept 10 minutes ([server/http/network-reads.js](server/http/network-reads.js), `WATCH_SEARCH_URL`, `https://search.openvibe.network` in production, off elsewhere); a section with nothing to show is left out | | `/watches` | your watches: name, source host, condition in words, status, last check state and time, last value, next due — paged with `before` | | `/watches/new?template=page\|price\|feed\|json\|event\|webhook\|lan` | the one create form, showing only the fields the chosen kind needs; a link may fill it in (`url`, `name`, `value_path`, `op`, `value`, `event`, `event_filter`, `agent_id`), and the person still reviews and saves | | `/watches/:id` | the watch in words, status with pause/resume/delete/check-now, the observation history (time, value, changed, condition met) and the check runs with their states in plain words, both paged | | `/watches/:id/edit` | the same form, PATCH semantics | | `/how-it-works`, `/updates` | the carrier order, the manners and every state, in plain words; Watch's own changelog | | `/robots.txt`, `/sitemap.xml`, `/llms.txt(/-full)` | discovery: the home page and how-it-works are listed; `/watches` and `/auth` are disallowed | **Seven starting points**, each a page of the same form ([server/http/pages.js](server/http/pages.js) `TEMPLATES`): | Template | Source | Extraction | Condition | |---|---|---|---| | `page` | http | html, an optional CSS selector | `changed` | | `price` | http | css selector or regex | `lt`/`lte`/`gt`/`gte` | | `feed` | feed | the newest item (`latest`) | `changed` | | `json` | api | json with an optional `value_path` | any of the ops | | `event` | event (a channel goes live, a blog post, a wiki page published …) | the event | `changed` | | `webhook` | webhook (the watch's own signed address) | json with an optional `value_path` | any of the ops | | `lan` | node (a host:port on the owner's own network, checked by their own computer) | `status` | `eq` `down` | Besides **Notify me**, the form can run one of the person's OpenVibe.Actor agents (`actor-agent`, by its `agt_` id, with an optional note) or start a coding task on OpenVibe.Codes in a repository they name (`codes-run`) when the watch fires. The cadence is a select (15 minutes, 1 hour, 6 hours, 1 day); 15 minutes is the shortest a person may choose here (the contract's floor is 30 seconds, for a service). A validation error re-renders the form with the person's own input and the registry's own message next to the field it names. The kind whose carrier does not exist yet (`run`) and the `ai` extraction are **not offered** — and a watch of such a kind created through the API still answers `watch.carrier_unavailable` when checked from the site, fetching nothing. An event watch is checked when its event arrives and a webhook watch when a signed delivery reaches its `/hooks/` address, so Check now answers `watch.event_awaited` or `watch.webhook_awaited`, fetching nothing. Check-now from the page respects the API's limits and its refusals: a second check while one runs or a check of a paused watch is the API's 409, said in words; a kind without a carrier is the API's 422; and the manual-check and write budgets (10/120 and 30/600 per person, a minute/hour) are enforced with a friendly page. "Notify me" stores the notification action: when the condition fires the owner gets one notification on OpenVibe (through OpenVibe.Network), and the watch page lists what each firing did under "What it did when it fired". ## The watch model A Watch is: `source`, `cadence` (or an event pattern), `extraction`, `comparison`, `condition`, `action`, `budget`, `retention`, plus `owner`, `status`, `labels`. It is validated against `watch.watch-request@1` on the way in and served as `watch.watch@1` on the way out — both with openvibe-contracts' own validator, never a hand copy of the schema. Rules a schema cannot carry ([server/registry.js](server/registry.js)): a create requires source, extraction, condition and action; a source that polls (`http`, `feed`, `api`, `node`, `run`) needs `cadence.every_sec`; an `event` or `webhook` source is pushed to and never polls (`cadence: null`); the headers the fetcher owns (`Host`, `Cookie`, `User-Agent`, `If-None-Match`, …) cannot be overridden. **Ownership is the authority.** Every read and write is scoped to the acting subject — a person's `usr_…`/`agt_…` bearer token, or a first-party service acting for a person with `X-OV-Subject` — and another owner's watch is **404, never 403**: an id must not confirm that someone else's watch exists. A service principal that does not name a person owns what it creates. ## Carrier preference (binding, plan T18) webhook/event → ETag/Last-Modified → feed → API → and only then browser polling through Run The rungs run from cheapest to most expensive, and `source.kind` fixes which one a watch uses. This release carries the pull rungs and event sources: | Kind | Carrier | State | |---|---|---| | `http` | one URL, conditional | implemented | | `feed` | RSS 2.0 / RSS 1.0 (RDF) / Atom 1.0 → `{ items, latest, count }` | implemented | | `api` | JSON/XML with `items_path` + `fields`, or the body itself | implemented | | `event` | delivered by OpenVibe.Events (see [Event sources](#event-sources)) | implemented | | `webhook` | a signed POST to `/hooks/` (see [Webhook sources](#webhook-sources)) | implemented | | `run` | browser/code polling through OpenVibe.Run | step 6 | | `node` | a probe from the owner's own machine through OpenVibe.Bot and OpenVibe Link (see below) | implemented (step 7) | **Link sources** (plan T18 step 7, contracts 0.167.0, [server/carriers/node.js](server/carriers/node.js)): `{ kind: 'node', node_id: 'dev_…', probe: { target, timeout_s } }`. The check runs from the owner's own machine: - **The call**: Watch asks OpenVibe.Bot (`WATCH_BOT_URL`, default `http://127.0.0.1:4630`) with its own token (`bot.computer.control`) and the owner in `X-OV-Subject`. Bot answers only for that person's machine, only where they turned on "Let my watches check things from this computer", and Watch may send nothing but the read-only `probe`. - **Targets**: the machine's OpenVibe Link reaches only its own network (private, loopback or link-local addresses). - **http(s) targets**: the answer's body is extracted like a page, and a status other than 200/203 is an `http_error`. - **tcp targets**: the document is `{ open, status: 'up' | 'down', ms }`. - **Owners**: an agent-owned watch is never probed (`node_person_only`). - **The form**: its "Something on your network goes down" template makes one: a machine id, a host:port, and the condition `status` eq `down`. A kind whose carrier does not exist yet is **accepted by the registry but never fetched**: the check is recorded `skipped` with `watch.carrier_unavailable` and the reason names the step. That is what keeps a half-built rung from silently turning into polling. ## Event sources A watch whose source is `{ "kind": "event", "pattern": "host.release.published", "filter": { "repo": "OpenVibe.Bot" } }` (and `cadence: null`) is checked when a matching OpenVibe.Events event arrives, and never on a cadence, so it costs nothing while nothing happens (`server/event-sources.js`, `server/carriers/event.js`). - **Matching.** The pattern matches the event type as Events matches topics: `*` stands for one or more whole segments (`media.*.ready`). Every `filter` key must equal the event's value: `subject.id`, `subject.type`, or a top-level `payload` field. - **What the extraction reads.** The event is the check's document: `{ event_type, event_id, timestamp, subject, payload }`, so `{ "kind": "json", "value_path": "payload.version" }` takes the released version. Comparison, condition, observations and actions work exactly as for a fetched page. - **Only public events.** Anyone may own a watch, and an internal or private event is not theirs to read, so only events with `visibility: "public"` wake a watch. - **Once per event.** Events delivers at least once; each `event_id` is handled once (`event_source_receipts`, kept seven days), so a redelivery is answered `duplicate`. - **A budget.** At most `WATCH_EVENT_MAX_PER_HOUR` (60) event checks per watch an hour. More are dropped and counted, so a busy pattern cannot turn one watch into a write storm. - **Subscriptions.** Watch keeps one Events subscription per distinct pattern of an active event watch, delivered to the same signed loopback route as the account events (`POST /internal/events`, `WATCH_EVENTS_SECRET`). A sync at boot, every `WATCH_EVENT_SYNC_MS` (10 minutes) and two seconds after an event watch changes creates the missing ones, re-enables one a watch needs again, and disables the ones no watch needs. It never touches the account subscriptions or another endpoint's. It is off without `EVENTS_URL`, the secret or the OAuth client secret. - **Check now** on an event watch answers 422 `watch.event_awaited`: there is no event to read. ## Webhook sources A watch whose source is `{ "kind": "webhook" }` (and `cadence: null`) is woken by a POST to `https://openvibe.watch/hooks/` from anything that can call a webhook: GitHub, a CI, a store, another OpenVibe product (`server/webhook-sources.js`, `server/carriers/webhook.js`). - **The address is the watch's own.** Its stored endpoint is `/internal/hooks/` (the form `watch.watch@1` names), whatever a caller sent, so a watch can never point at another watch's address. - **The secret is shown once.** Creating the watch answers `{ watch, webhook: { url, secret } }`. The secret (`whk_…`) is never shown again; `POST /api/v1/watches/:id/webhook-secret` replaces it (the old one stops working at once). It is kept in full because checking a signature needs it (`webhook_secrets`), and it goes with its watch: deleting the watch or the account removes it. - **Signing.** `X-Hub-Signature-256: sha256=` (GitHub's form), `Authorization: Bearer ` for a sender that cannot sign, or Telegram's `X-Telegram-Bot-Api-Secret-Token` (give the secret as `setWebhook`'s `secret_token`). Compared in constant time. - **A sender's own secret (agent plan A3).** Slack, Stripe and Discord sign with a secret or key they make. Paste it on the watch's page ("Signed by Slack or Stripe?") or send `POST /api/v1/watches/:id/webhook-secret` with `{ "provider": "slack" | "stripe" | "discord", "secret": "…" }`: that scheme then checks every delivery, and the secret is kept (never shown again) instead of Watch's own. A bad provider or secret answers 422 and changes nothing; a new secret of Watch's own (the same call with no body) replaces it. - `slack`: `X-Slack-Signature: v0=:">`, the timestamp within five minutes. Slack's `url_verification` is answered with its `challenge`, and a retry (`X-Slack-Retry-Num`) is acknowledged without checking twice. - `stripe`: `Stripe-Signature: t=