Monitor a web page¶
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¶
- 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.
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 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 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_listreturns eachjobIdwith its status,triggerCount,consecutiveFailures,lastCheckedAt,lastTriggeredAt,nextRunAtandexpiresAt. 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¶
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.
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.