Monitor a web page¶
A page monitor is a durable, GET-only watch an agent starts on a URL. The platform re-fetches the page on a fixed interval and evaluates your conditions on every tick — text or CSS-selector rules on HTML, path and comparator rules on JSON APIs, plus change detection. When a condition trips, the watch delivers an event back into Hadron that mints an agent run, so your agent wakes up and acts on the change.
Reach for it when you want "notify my agent when this page changes" or
"wake my agent when this API field crosses a threshold" — a price drops
below a number, a status flips to open, a phrase appears on a page.
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¶
- An App and an agent you can run as a multi-node headless run. If you don't have one yet, start with Building an agent.
- Familiarity with a flow node's
data.toolsfield and the run's policy chain — the same machinery used to call external MCP tools.
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.
hadron node update 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 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 |
Suppression window when firePolicy is cooldown. |
|
headers |
Non-secret request headers only. Never put authorization or cookie material here. | |
credentialsNodeUrn |
A server-encrypted credential for authenticated pages — see the caveat below. |
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.
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 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. |
text_contains |
{id, value} |
The page's visible text contains value. |
content_changed |
{id} |
The page's normalized content differs from the previous observation. |
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. |
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 forcooldownSeconds.
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 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_listreturns eachjobIdwith its status and counters (checks, triggers, consecutive failures). It never returns credentials or page content. - Cancel a watch —
web_poll_cancelwith itsjobId. 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¶
web_poll_start accepts a credentialsNodeUrn that points at a Hadron
node holding a server-encrypted credential (a bearer token, basic
auth, or a custom header). The secret is never shown to the model, and
it's bound to a URL prefix so it can only be sent to the origin it
belongs to.
The injection side is shipped, but there is not yet a builder-facing way to create such a credential node — that write surface is still to come. Until it lands, use page monitors on public or unauthenticated URLs. Authenticated monitoring will get its own how-to once you can mint the credential node from the portal or CLI.
Related¶
- Call external MCP tools from a flow — the same
data.tools+ policy-chain machinery, applied to external tools. - Multi-node run flows — the
data.toolsfield, the action budget, triggers, and the failure codes a monitor's run obeys. - How multi-node automations work
— the run model the woken
INTEGRATIONrun plugs into. - Building an agent — set up the App and agent a monitor runs under.