Skip to content

Monitor a web page

PortalMCPCLIIntermediate~15 min

Your agent can wake up on its own when a web page changes — when a price drops below a number, a status flips to open, or a phrase appears — and act on it, instead of you checking the page by hand.

It works through a page monitor: a durable, GET-only watch your agent starts on a URL. Hadron re-fetches the page on a fixed interval and checks your conditions each time — text or CSS-selector rules on HTML, path and comparator rules on JSON APIs, plus change detection. When a condition trips, Hadron starts a new run of your agent with what it found.

Two parts of this need the hadron CLI: restricting which actions the watch's trigger may take (Step 1, recommended), and storing a credential for a page behind a login. Everything else can be done in the portal or by an agent.

A monitor only ever reads. It issues a GET, never submits a form or writes anything, and the URL is frozen when the watch starts.

Prerequisites

Step 1: Give the agent the polling tools

Monitoring is exposed as three run tools, all governed by the single policy action tool.web_poll:

Tool Does
web_poll_start Begin a watch. Returns a jobId and an expiresAt.
web_poll_cancel Stop a watch by jobId.
web_poll_list List the org's active watches — status and counters only, never a secret.

Two separate things must both be true, or the agent silently won't monitor anything:

1. Declare the tools on the node that calls them. data.tools is per node — a run only has the tools declared on the node making the call. In a single-node watcher flow that's the entry node; in a multi-step flow, put the tools on whichever node calls web_poll_start. Miss this and the agent never calls the tool — the run completes and the model answers in prose, with no error.

An agent can set this with hadron_update_node_data. The merge is shallow, so the tools list you send replaces the node's whole list. From the CLI:

hadron node update hrn:node:acme.com:ops:flow:watcher \
  --data-merge '{"tools":["web_poll_start","web_poll_cancel","web_poll_list"]}'

2. Make sure the policy chain permits tool.web_poll. Each call is authorized against the run's policy chain — org ∧ user ∧ app ∧ trigger, where every present layer must allow the action. If none of those layers pins an allow-list, the tool is permitted by default. If you do pin the trigger (the recommended posture), the allow-list must include the run-time verbs the flow needs and the tool — otherwise the call fails ACTIVATION_DENIED (or the run won't launch at all):

hadron webhook create --app acme.com:ops --name start-watch \
  --entry hrn:node:acme.com:ops:flow:watcher \
  --policy '{"allow":["run.execute","llm.invoke","tool.web_poll"]}'

Then fire that webhook once to start the watch. (A manual hadron run trigger also works when a higher layer already permits tool.web_poll; the --policy allow-list lives on schedule/webhook triggers, not on node update.)

The tool must be declared and permitted

Permitting tool.web_poll does not add the tool — it only allows it. Listing it in data.tools while a pinned policy omits it lets the model call a tool the chain then denies. You need both. (And remember a node that declares tools can't also carry an extractionSpec — they're mutually exclusive per node.)

Step 2: Start a watch

The agent calls web_poll_start. Its arguments:

Argument Required Meaning
url ✓ The absolute http(s) URL to watch. Frozen at creation — to watch a different URL, start a new monitor.
conditions ✓ 1–10 condition objects (see Step 3).
intervalSeconds ✓ How often to re-fetch. The service enforces a floor; a value below it is rejected.
ttlSeconds ✓ How long the watch lives. Mandatory and capped — every watch expires, and you get a poll.expired event when it does, so a watch never silently vanishes.
contentKind auto (default), html, or json — how to parse the response.
mode any (default) or all — whether one matching condition or every condition must match to fire.
firePolicy once (default), every_change, or cooldown (see Step 4).
cooldownSeconds with cooldown Suppression window. Required when firePolicy is cooldown.
headers Non-secret request headers only. Never put authorization or cookie material here.
credentialName The name of a stored webfetch-auth secret, for a page behind a login — see Monitoring authenticated pages.
credentialScope user or org: look credentialName up at exactly this scope. By default a user secret is tried first, then the organization's.

A minimal watch — fire once when a phrase appears on a page, and expire after 15 minutes:

{
  "url": "https://example.com/",
  "conditions": [
    { "id": "phrase", "type": "text_contains", "value": "Example Domain" }
  ],
  "intervalSeconds": 60,
  "ttlSeconds": 900,
  "firePolicy": "once"
}

web_poll_start returns the jobId (use it to cancel) and the expiresAt timestamp.

Limits

By default the interval floor is 60 seconds and a watch may live at most 30 days. A ttlSeconds over the cap is rejected, not shortened.

Step 3: Write conditions

Each condition is an object with a caller-chosen id (so you can tell which one fired). A watch holds up to 10. The top-level mode decides whether any (default) or all of them must match.

On any page, HTML or JSON:

Type Fields Fires when
text_contains {id, value} The page's text contains value.
content_changed {id} The page's normalized content differs from the previous observation.

On HTML pages:

Type Fields Fires when
selector_exists {id, selector} A CSS selector matches at least one element.
selector_text {id, selector, op, value?} The first match's text satisfies op: contains, equals, regex, or changed.

On JSON APIs:

Type Fields Fires when
json_path {id, path, op, value?} The value at a dot/bracket path (e.g. items[0].price) satisfies op: eq, ne, gt, gte, lt, lte, contains, exists, or changed.

One watch is either HTML or JSON. selector_* and json_path conditions cannot be mixed in one watch, and a contentKind that contradicts the conditions is rejected.

A JSON field watch — wake when a status endpoint reports open:

{
  "url": "https://api.example.com/v1/status",
  "contentKind": "json",
  "conditions": [
    { "id": "status", "type": "json_path", "path": "data.status", "op": "eq", "value": "open" }
  ],
  "intervalSeconds": 120,
  "ttlSeconds": 3600
}

First tick: change vs. absolute conditions

A change condition (content_changed, or any changed op) records a baseline on the first tick and does not fire from it — it fires on a later change, including a watched element or value appearing or disappearing.

An absolute condition (e.g. json_path … lt 100) may fire on the very first tick if it's already true. "Notify me when the price is under 100" firing immediately because it already is, is correct.

Step 4: Choose a fire policy and TTL

firePolicy controls what happens after a condition trips:

  • once (default) — fire the first time it triggers, then the watch is done.
  • every_change — re-fire only when the observed content actually changes from the last notification. A condition that merely stays true does not re-fire every tick.
  • cooldown — after firing, suppress re-fires for cooldownSeconds.

Regardless of fire policy, ttlSeconds is the backstop: when it elapses the watch ends and emits a poll.expired event. Set it deliberately — there is a cap, and a long watch still expires.

Step 5: Handle the run that wakes

When a watch fires, it delivers an event into Hadron, which mints a new agent run — trigger kind INTEGRATION, parented to the run that started the watch. The event carries which condition matched and a capped excerpt of the page. Three event kinds reach your agent:

  • poll.triggered — a condition matched.
  • poll.expired — the TTL elapsed.
  • poll.failed — the watch could not fetch or evaluate the page.

The woken runs appear in the portal on your App's Runs tab, with INTEGRATION in the Trigger column.

The page excerpt is untrusted external content

The excerpt is whatever was on the page — it may be attacker-authored. Hadron wraps it in its untrusted-content framing (a preamble telling the model to treat it as data, never as instructions), but design the woken agent accordingly: use the excerpt as a signal to act, and never let page text redirect what the agent does. This is the same posture as any tool that pulls in outside content.

Step 6: Manage watches

  • List the org's active watches — web_poll_list returns each jobId with its status, triggerCount, consecutiveFailures, lastCheckedAt, lastTriggeredAt, nextRunAt and expiresAt. It never returns credentials or page content.
  • Cancel a watch — web_poll_cancel with its jobId. Cancelling an already-cancelled job is a no-op, not an error.

A watch you never cancel still ends on its own when ttlSeconds elapses.

Monitoring authenticated pages

For a page behind a login, store the credential as an encrypted webfetch-auth secret, then name it in web_poll_start's credentialName. The secret is injected on the server and never shown to the model, and it is bound to a URL prefix, so it is only ever sent to the site it belongs to.

This needs the CLI — there is no portal or agent tool for creating secrets. The value is read from stdin, never from a flag:

printf '%s' "$TOKEN" | hadron secret create --name poll-auth \
  --scope org --owner hrn:org:acme.com \
  --kind webfetch-auth --type bearer \
  --url-prefix https://api.example.com/ --value-file -

A secret belongs to a user or an organization — nothing narrower. The CLI's help still offers app and memory scopes; the server refuses them. --type is bearer, basic (with --username) or header (with --header-name). Then start the watch with "credentialName": "poll-auth". By default the name is looked up as a user secret first, then the organization's; pass credentialScope to pin one.