An uptime check can tell you that a page returned HTTP 200. It cannot tell you that the price changed unexpectedly, a form stopped submitting, or a client-side deployment removed an important field.
Watchdog is an open-source TypeScript example that checks the experience, not only the response code. It remembers a healthy baseline, wakes on a schedule, drives a real browser through the page, records evidence, and sends an SMS when the result changes.
The application combines a Telnyx Edge Stateful Actor, Agent Browser, SQL, KV, AI Inference, and Messaging. This tutorial explains how those pieces fit together and why its retry rules matter.
Beta notice: Telnyx Agent Browser is currently in beta. Request shapes, actions, and error behavior may change. Check the current Agent Browser documentation before using this pattern in production.
One actor owns one watched hostname. Its state includes:
The first scheduled run captures the baseline. Later runs:
finally block.
The verdict is either healthy or broken with a reason. A broken verdict opens or updates an incident. A later healthy run closes that incident with before-and-after evidence.
The actor identity comes from the target hostname:
export function watchId(targetUrl: string): string {
return new URL(targetUrl).hostname
}
const actor = env.WATCHDOG.idFromName(watchId(targetUrl))
Two URLs on the same hostname belong to the same watchdog. A different hostname gets a separate actor with independent state, scheduling, and incident history.
This makes the watched property a durable entity instead of a row that a stateless cron task must reconstruct on every run.
When a watch is armed, the actor stores its configuration and schedules both the first check and the recurring daily check:
const dailyId = await this.every(
86400,
"runCheck",
{ reason: "scheduled" },
{ id: `daily-${ownerId}` },
)
await this.schedule(
5,
"runCheck",
{ reason: "first", run_key: firstRunKey },
{ id: `first-${ownerId}` },
)
The first run occurs shortly after setup. Subsequent checks run daily.
The actor calls the Agent Browser REST API to create a short-lived Chromium session. The request restricts navigation to the watched hostname and applies hard lifetime limits:
{
"policy": {
"allowed_domains": ["shop.example.com"]
},
"limits": {
"lifetime_s": 900,
"idle_timeout_s": 120
},
"idempotency_key": "2026-11-06"
}
The actor polls the session until it becomes ready. A session returned by an idempotent replay may already be closed, failed, or expired, so the code checks for terminal states and creates a fresh session when needed.
Agent Browser provides isolated sessions plus structured actions for navigation, observation, interaction, and screenshots. The official Agent Browser overview describes the browser as short-lived and isolated, with semantic observations designed for model tool use.
After navigation, an observe action returns an observation_id, a text representation of the page, and element refs. The model works with those refs rather than pixel coordinates.
The watchdog asks the configured inference model to identify four elements:
It then performs fill and click actions using both the current observation ID and the selected ref. The Agent Browser actions documentation requires observation_id for ref-based interactions and documents the available action types.
The model does not decide whether the HTTP request succeeded. It helps map the structured observation to the page elements the deterministic workflow needs.
The first successful run stores the observed price and form labels as the baseline:
interface Baseline {
price_text: string
form_labels: string[]
}
Later runs compare the pre-submit observation with that baseline. The post-submit observation has a different purpose: it proves whether the interaction reached the expected success state.
Keeping those observations separate avoids a subtle mistake. A form submission may navigate or rerender the page, so the final page is not necessarily the correct source for pricing comparison.
The Stateful Actor owns the current lifecycle, while WATCHDOG_DB stores an audit-friendly ledger.
The runs table records values including:
The incidents table records the first broken time, reason, evidence artifact IDs, and open or closed status.
Scheduled runs use the UTC date as their stable run key. Before starting, the actor checks whether that key already has a verdict:
const prior = await this.rows<{ verdict: string | null }>(
"SELECT verdict FROM runs WHERE run_key = ?",
runKey,
)
if (prior[0]?.verdict && (reason === "scheduled" || reason === "first")) return
That guard makes a restarted or replayed scheduled task a no-op instead of a duplicate browser run or duplicate incident.
KV holds the small indexes needed to route incoming commands:
watch/<hostname> -> owner phone
owner/<phone> -> watched hostnames
incident/<id> -> watched hostname
The owner can control the watchdog by SMS:
watch https://shop.example.com/pricing
status
recheck now
recheck <incident_id>
Incoming messaging webhooks are Ed25519-verified in live mode. The sender is then checked against the watch owner before the actor can expose status or queue another browser run. Unknown senders cannot trigger paid sessions or read incident state.
The most reusable part of this sample is its error model. Browser automation is different from a read-only API request because retrying a mutation may repeat an effect.
The DOM may change after an observation. Agent Browser reports stale_ref or stale_observation as an action failure inside an HTTP 200 response.
The watchdog does not retry the old ref. It observes again, asks for current refs, and retries once. A second consecutive stale result is recorded as an incident note rather than guessed away.
An execution_lost result does not prove that a fill or click failed to take effect. Repeating the action immediately could submit a form twice.
The actor first observes the current page and checks for the expected effect. It repeats the mutation only when the effect did not land, using a fresh observation. If the browser session is terminal, the actor reconciles the original outcome before using a new session for remaining work.
This follows the official recovery guidance: an unknown outcome must be inspected before a mutation is repeated.
A 429 quota_exceeded response means the concurrent browser-session quota is full. It is not handled with a tight retry loop.
The actor closes any session it owns, schedules runCheck for 60 seconds later with the same run key, and returns. The stable run key keeps the delayed attempt replay-safe.
Clone the sample and install its dependencies:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/watchdog
npm install
cp .env.example .env
Leave this setting enabled initially:
DEMO_MODE=true
Demo mode replays recorded Agent Browser envelopes from demo/browser-fixtures.json. It exercises baseline capture, stale-ref recovery, a broken verdict, incident creation, and fix verification without opening a live browser session or sending an SMS.
Run the smoke test:
npm run smoke
The test covers the end-to-end demo loop, restart behavior, incident deduplication, owner authorization, live-mode webhook refusal without a valid signature, and fixture consistency.
The sample's telnyx.toml declares:
WATCHDOG actor namespaceWATCHDOG_DB SQL storageWATCHDOG_INDEX KV storageTELNYX binding
Replace the placeholder resource IDs and configure the required secrets before deployment. Use placeholders rather than committing credentials or real phone numbers.
Generate binding types and deploy:
npx telnyx-edge types
npm run deploy
Agent Browser runs in the cloud, so the target must be a public HTTPS URL. It cannot reach localhost or a private service. The sample includes a fixture page available from the deployed function at /fixture.
Arm a watch through the HTTP API:
curl -X POST "$PUBLIC_BASE_URL/watch" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-public-host.example.com/fixture",
"owner": "<OWNER_PHONE_E164>"
}'
For live mode, configure TELNYX_API_KEY, TELNYX_PUBLIC_KEY, WATCH_OWNER_PHONE, FROM_NUMBER, and a supported AI_MODEL, then set DEMO_MODE=false. Point the Messaging Profile webhook to:
https://<YOUR_DEPLOYED_HOST>/webhooks/messaging
The sample demonstrates the architecture, but production monitoring needs additional policy:
Watchdog is a website-monitoring example, but its pattern applies to any stateful browser workflow:
The complete TypeScript implementation, recorded browser fixtures, API notes, and smoke test are available in the Watchdog code example.