Step DSL reference
The deterministic step language every Watch compiles to, the Watch definition that wraps it, and JSON examples that validate against the published schema.
v1Updated
Every Watch is stored as an explicit, versioned JSON definition. The natural-language composer proposes this format; you can also write or edit it directly in the Steps tab or through the API (POST /v1/journeys/{journeyId}/watches and POST /v1/watches/{watchId}/versions). Definitions are validated at the edge against the same schema the platform uses (WatchDefinitionSchema in @scoutsentinel/core), then cross-checked: unique probe, step and assertion ids; every assertion stepId resolves to a step; step types match the probe type; cron expressions parse.
Every JSON example on this page is validated against that schema as part of the website build.
Watch definition shape
| Field | Type | Meaning |
|---|---|---|
intent |
string, 1 to 2000 chars | The capability in plain language. Used in Event titles and reports. |
channel |
web, api, voice, email |
The customer channel this Watch assures. |
probes[] |
1 to 20 probe definitions | Each probe runs its steps in order on one executor. |
assertions[] |
1 to 100 assertions | The contract; see the assertions reference. |
schedule |
interval or cron | See schedule. |
materiality |
policy object | When failures become an Event; see materiality. |
locationPolicy |
policy object | Which location classes and regions run the Watch. |
notifications |
policy object | Notification channel ids and when to send. |
costClass |
http, browser, voice, email, mixed |
Cost class recorded for fair-use budgets. |
requiredEvidence[] |
evidence kinds | Kinds that must be present for an Observation to count as complete. |
Probe definition
| Field | Type | Meaning |
|---|---|---|
id |
string, up to 64 chars | Unique within the Watch. |
type |
http, api, dns, tls, browser, ct, origin_bypass, voice, email |
The executor. Step types are restricted per probe type (table below). |
steps[] |
1 to 100 steps | Run in order. The first fail or error stops the run; later steps are recorded as skipped. |
timeoutMs |
1000 to 600000 | Whole-probe budget. |
captures |
capture policy | screenshotOn (every_step, failure_and_boundary, first_run_and_change), html, har, trace booleans. The failing step always gets a screenshot and page HTML regardless of policy. |
locationPolicy |
optional | Overrides the Watch-level location policy for this probe. |
| Probe type | Allowed step types |
|---|---|
browser |
navigate, click, fill, select, press, waitFor, expect, capture, http.request |
http, api |
http.request |
dns |
dns.resolve |
tls |
tls.inspect |
origin_bypass |
origin.probe, http.request |
voice |
voice.call |
email |
email.send, email.expectInbound |
Schedule
Two kinds. Interval schedules run on slots aligned to the Unix epoch, so every scheduler tick agrees on the same slot and a re-delivered job is recognised by its idempotency key {probeId}:{slot}. The platform minimum is 60 seconds; your plan’s minimum interval (minIntervalSeconds in GET /v1/entitlements) applies on top.
{ "kind": "interval", "everySeconds": 300 }
{ "kind": "cron", "expression": "*/15 6-22 * * 1-5", "timezone": "Europe/London" }
Cron expressions have five fields (minute, hour, day of month, month, day of week) and accept numbers, *, */n, a-b, a-b/n and lists. timezone is an IANA name.
Materiality policy
{
"consecutiveFailures": 2,
"corroboratingLocations": 1,
"windowSeconds": 1800,
"autoResolveAfterPasses": 2,
"behaviourOpensEvent": false
}
Those are the defaults; every field may be omitted. An outcome Event opens after consecutiveFailures failing runs with the same failure boundary inside windowSeconds, seen from at least corroboratingLocations distinct locations. It resolves automatically after autoResolveAfterPasses consecutive passes. unknown runs never extend or break a failure streak and never open an outcome Event; three consecutive unknowns open a low-severity coverage Event instead. With behaviourOpensEvent true, a passing run whose behaviour fingerprint changed opens a behaviour Event.
Location policy
{ "classes": ["edge", "regional"], "regions": ["lhr", "fra", "syd"], "minLocations": 2 }
classes is one or more of edge, regional, private, partner; regions is optional; minLocations (default 1) is the number of distinct locations expected per slot and feeds coverageLocations assertions. See runner locations.
Notifications
{ "channels": ["<notification channel id>"], "onOpen": true, "onResolve": true, "onBehaviourChange": false, "minSeverity": "low" }
channels holds notification channel IDs from GET /v1/notification-channels. Select the destinations explicitly: an empty list does not select channels marked as default. Legacy organisation-level severity routes may also receive matching events. An enabled channel still needs an active integration and matching delivery rules; saving a policy does not prove delivery. Review notification channels and the delivery log under Integrations.
Locators
Browser steps address elements with a target locator that follows Playwright semantics. A locator sets at least one of:
| Field | Meaning |
|---|---|
testId |
data-testid attribute |
role and name |
ARIA role plus accessible name ({ "role": "button", "name": "Get quote" }) |
label |
Form control label text |
placeholder |
Placeholder text |
text |
Visible text |
css |
CSS selector, last resort |
Resolution order is testId, then role with name, then label, placeholder, text and finally css. Prefer the stable end of that list: test ids and accessible names survive redesigns, CSS selectors change most often. Every locator shape below is valid.
[
{ "testId": "quote-price" },
{ "role": "button", "name": "Get quote" },
{ "label": "Postcode" },
{ "placeholder": "Search destinations" },
{ "text": "Continue to payment" },
{ "css": "form[data-step=travellers] button[type=submit]" }
]
Secrets
Plaintext never appears in a definition. Secrets are stored per organisation under Settings, Secrets (POST /v1/secrets, names in UPPER_SNAKE_CASE), envelope-encrypted, and referenced by name:
fillsteps takesecretRefinstead ofvalue.http.requestheader values may be{ "secretRef": "NAME" }and are resolved directly.http.requestbodies and string header values may contain{{secret:NAME}}placeholders; list each name in the step’ssecretRefsso the platform knows which secrets the run needs.
Values are decrypted only inside the execution step, are redacted from screenshots, HTML, HAR, logs and step messages, and never reach a model. GET /v1/watches/{watchId} reports requiredSecrets and missingSecrets so you can see a reference that has no value yet.
Step types
Every step has an id (unique in the Watch, up to 64 chars), a type, and an optional description (up to 500 chars) used in Event titles and the steps list. Fields marked optional may be omitted.
Browser steps
navigate: url (absolute).
{ "id": "open", "type": "navigate", "url": "https://shop.example.com/quote", "description": "Open quote form" }
click: target.
{ "id": "submit", "type": "click", "target": { "role": "button", "name": "Get quote" } }
fill: target plus exactly one of value or secretRef.
[
{ "id": "postcode", "type": "fill", "target": { "label": "Postcode" }, "value": "SW1A 1AA" },
{ "id": "password", "type": "fill", "target": { "label": "Password" }, "secretRef": "TEST_ACCOUNT_PASSWORD" }
]
select: target and value (the option value).
{ "id": "cover", "type": "select", "target": { "label": "Cover type" }, "value": "comprehensive" }
press: key and an optional target to focus first.
{ "id": "search", "type": "press", "key": "Enter", "target": { "role": "searchbox" } }
waitFor: target or urlPattern (at least one) and optional timeoutMs (up to 120000).
[
{ "id": "wait-price", "type": "waitFor", "target": { "testId": "quote-price" }, "timeoutMs": 15000 },
{ "id": "wait-confirm", "type": "waitFor", "urlPattern": "/confirmation" }
]
expect: a check discriminated on kind.
kind |
Fields | Passes when |
|---|---|---|
textVisible |
text |
The text is visible on the page |
elementVisible |
target |
The element is visible |
elementHidden |
target |
The element is absent or hidden |
urlMatches |
pattern |
The current URL matches the pattern |
title |
contains |
The document title contains the string |
latencyBelow |
ms |
The step so far completed inside the budget |
[
{ "id": "price-visible", "type": "expect", "check": { "kind": "elementVisible", "target": { "testId": "quote-price" } } },
{ "id": "no-error", "type": "expect", "check": { "kind": "elementHidden", "target": { "role": "alert" } } },
{ "id": "on-quote", "type": "expect", "check": { "kind": "urlMatches", "pattern": "/quote/result" } },
{ "id": "title-ok", "type": "expect", "check": { "kind": "title", "contains": "Your quote" } },
{ "id": "fast", "type": "expect", "check": { "kind": "latencyBelow", "ms": 3000 } }
]
capture: kind (screenshot, html, trace, har) and an optional label (up to 100 chars). Adds an evidence object at that point in addition to what the probe’s capture policy records.
{ "id": "shot", "type": "capture", "kind": "screenshot", "label": "Quote result" }
HTTP and API steps
http.request: method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS), url, optional headers (string or { secretRef } values), optional string body, optional secretRefs, optional expect.
expect field |
Meaning |
|---|---|
status |
Exact status code |
statusRange |
[min, max] inclusive |
headerContains |
Map of header name to substring |
bodyContains |
Substring of the response body |
jsonPath |
{ "path": "$.a.b[0]", "equals": <value> } compared by deep equality |
latencyBelowMs |
Total request time budget |
{
"id": "post-quote",
"type": "http.request",
"method": "POST",
"url": "https://shop.example.com/api/quotes",
"headers": {
"Content-Type": "application/json",
"Authorization": { "secretRef": "QUOTE_API_TOKEN" },
"X-Client": "scoutsentinel/{{secret:CLIENT_ID}}"
},
"body": "{\"destination\":\"LIS\",\"travellers\":2,\"nights\":14,\"apiKey\":\"{{secret:QUOTE_API_KEY}}\"}",
"secretRefs": ["CLIENT_ID", "QUOTE_API_KEY"],
"expect": {
"statusRange": [200, 299],
"headerContains": { "content-type": "application/json" },
"jsonPath": { "path": "$.status", "equals": "quoted" },
"latencyBelowMs": 3000
}
}
HTTP probes send the ScoutSentinel user agent and the X-ScoutSentinel-Probe header on every request; see synthetic traffic. Redirects are followed manually and the chain is recorded; credentials are stripped when a redirect crosses hosts. The whole body is hashed and an excerpt kept as evidence.
DNS, TLS and origin steps
dns.resolve: name, recordType (A, AAAA, CNAME, MX, TXT, NS, CAA), optional resolver (cloudflare, google, authoritative; the runner’s system resolver when omitted), optional expect with contains and notContains lists.
{
"id": "apex-a",
"type": "dns.resolve",
"name": "shop.example.com",
"recordType": "A",
"resolver": "authoritative",
"expect": { "contains": ["203.0.113.10"], "notContains": ["198.51.100.1"] }
}
tls.inspect: host, optional port (default 443), optional expect with validForDays, issuerContains, sanIncludes, minProtocol (TLSv1.2 or TLSv1.3).
{
"id": "cert",
"type": "tls.inspect",
"host": "shop.example.com",
"port": 443,
"expect": { "validForDays": 14, "issuerContains": "Let's Encrypt", "sanIncludes": ["shop.example.com"], "minProtocol": "TLSv1.2" }
}
origin.probe (exposure, origin_bypass probes): host, ip, optional path, and compareWithEdge. Dials the IP directly with the Host header and SNI set to host; with compareWithEdge it also fetches through normal DNS and reports whether the responses match. The step passes whenever it could measure; whether a match is an exposure Event is decided by the exposure assertions.
{ "id": "origin", "type": "origin.probe", "host": "shop.example.com", "ip": "203.0.113.10", "path": "/", "compareWithEdge": true }
Voice and email steps
voice.call (voice probes): to, optional from, and a script of at least one action: waitForPrompt (containsText, timeoutMs), sendDtmf (digits, characters 0-9*#), expectSpeech (containsText), hangup.
{
"id": "claims-line",
"type": "voice.call",
"to": "+442079460000",
"script": [
{ "kind": "waitForPrompt", "containsText": "press 1 for claims", "timeoutMs": 20000 },
{ "kind": "sendDtmf", "digits": "1" },
{ "kind": "expectSpeech", "containsText": "claims team" },
{ "kind": "hangup" }
]
}
email.send (email probes): to, subject, body, optional expectReplyWithinSeconds. email.expectInbound: mailboxId (a ScoutSentinel probe mailbox), optional subjectContains, withinSeconds.
[
{ "id": "send-enquiry", "type": "email.send", "to": "support@shop.example.com", "subject": "Quote reference Q-1234", "body": "Synthetic enquiry from ScoutSentinel.", "expectReplyWithinSeconds": 900 },
{ "id": "auto-reply", "type": "email.expectInbound", "mailboxId": "probe-mailbox-1", "subjectContains": "We have received", "withinSeconds": 900 }
]
A complete Watch definition
Two probes, one browser and one API, with outcome, behaviour and coverage assertions. This document validates against WatchDefinitionSchema and passes the cross-field checks.
{
"intent": "UK customers can get a travel insurance quote every five minutes from three edge locations.",
"channel": "web",
"probes": [
{
"id": "quote",
"type": "browser",
"timeoutMs": 60000,
"captures": { "screenshotOn": "failure_and_boundary", "html": true, "har": false, "trace": true },
"steps": [
{ "id": "open", "type": "navigate", "url": "https://shop.example.com/quote", "description": "Open quote form" },
{ "id": "accept-cookies", "type": "click", "target": { "role": "button", "name": "Accept all" }, "description": "Accept cookies" },
{ "id": "postcode", "type": "fill", "target": { "label": "Postcode" }, "value": "SW1A 1AA", "description": "Enter postcode" },
{ "id": "cover", "type": "select", "target": { "label": "Cover type" }, "value": "comprehensive", "description": "Choose cover" },
{ "id": "submit", "type": "click", "target": { "role": "button", "name": "Get quote" }, "description": "Get quote" },
{ "id": "wait-price", "type": "waitFor", "target": { "testId": "quote-price" }, "timeoutMs": 15000, "description": "Wait for quote" },
{ "id": "price-visible", "type": "expect", "check": { "kind": "elementVisible", "target": { "testId": "quote-price" } }, "description": "Quoted price shown" },
{ "id": "shot", "type": "capture", "kind": "screenshot", "label": "Quote result" }
]
},
{
"id": "quote-api",
"type": "api",
"timeoutMs": 15000,
"captures": { "screenshotOn": "failure_and_boundary", "html": false, "har": true, "trace": false },
"steps": [
{
"id": "post-quote",
"type": "http.request",
"method": "POST",
"url": "https://shop.example.com/api/quotes",
"headers": { "Content-Type": "application/json", "Authorization": { "secretRef": "QUOTE_API_TOKEN" } },
"body": "{\"destination\":\"LIS\",\"travellers\":2,\"nights\":14}",
"expect": { "status": 200, "jsonPath": { "path": "$.status", "equals": "quoted" }, "latencyBelowMs": 3000 },
"description": "POST quote"
}
]
}
],
"assertions": [
{ "id": "quote-all", "kind": "outcome", "severity": "critical", "description": "Quote journey completes", "definition": { "type": "allStepsPass" } },
{ "id": "quote-latency", "kind": "outcome", "severity": "medium", "description": "Quote returned within 10 s", "definition": { "type": "latencyBelow", "stepId": "wait-price", "ms": 10000 } },
{ "id": "api-status", "kind": "outcome", "severity": "high", "description": "Quote API responds 200", "definition": { "type": "httpStatus", "stepId": "post-quote", "status": 200 } },
{ "id": "api-body", "kind": "outcome", "severity": "high", "description": "Quote API reports quoted", "definition": { "type": "jsonPath", "stepId": "post-quote", "path": "$.status", "equals": "quoted" } },
{ "id": "quote-behaviour", "kind": "behaviour", "severity": "info", "description": "Quote form behaviour stable", "definition": { "type": "behaviourStable", "tolerance": "normal" } },
{ "id": "two-locations", "kind": "coverage", "severity": "low", "description": "At least two locations report each slot", "definition": { "type": "coverageLocations", "min": 2 } }
],
"schedule": { "kind": "interval", "everySeconds": 300 },
"materiality": { "consecutiveFailures": 2, "corroboratingLocations": 2, "windowSeconds": 1800, "autoResolveAfterPasses": 2, "behaviourOpensEvent": false },
"locationPolicy": { "classes": ["edge"], "regions": ["lhr", "fra", "syd"], "minLocations": 2 },
"notifications": { "channels": [], "onOpen": true, "onResolve": true, "onBehaviourChange": false, "minSeverity": "low" },
"costClass": "mixed",
"requiredEvidence": ["screenshot", "html", "trace", "response", "har"]
}
Journey templates
GET /v1/watch-templates lists six starter definitions, and POST /v1/journeys accepts templateId with templateParams.baseUrl (and an optional path) to create a Journey from one. Each template is a complete, valid Watch definition you can then edit as a new version.
templateId |
Name | Channel | Cost class | Default path | Schedule | What it proves |
|---|---|---|---|---|---|---|
public_page_loads |
Public page loads | web | browser | / |
every 300 s | Homepage renders with its main heading within 5 s; behaviour stable |
sign_in_reachable |
Sign-in form reachable | web | browser | /sign-in |
every 300 s | Email, password and a “Sign in” button are visible |
quote_form |
Get a quote | web | browser | /quote |
every 300 s | Postcode and cover type submitted, a quoted price appears within 10 s |
api_health |
API health | api | http | /api/health |
every 60 s | 200, $.status equals ok, under 2 s |
checkout_reachable |
Checkout reachable | web | browser | /checkout |
every 300 s | A “Pay now” button is visible and the URL stays on /checkout |
contact_support |
Contact support page | web | browser | /contact |
every 600 s | A “Send message” button and “Call us” text are visible |
Browser templates capture screenshots on failure and at the boundary, page HTML and a trace, and require screenshot, html and trace evidence. The API template records a HAR and requires response and har. All six use the default materiality policy and edge locations. The api_health template built for https://shop.example.com is:
{
"intent": "Confirm the API health endpoint at https://shop.example.com/api/health responds 200 with a JSON body reporting status ok.",
"channel": "api",
"probes": [
{
"id": "health",
"type": "api",
"steps": [
{
"id": "health-get",
"type": "http.request",
"method": "GET",
"url": "https://shop.example.com/api/health",
"headers": { "Accept": "application/json" },
"expect": { "status": 200, "jsonPath": { "path": "$.status", "equals": "ok" }, "latencyBelowMs": 2000 },
"description": "GET health"
}
],
"timeoutMs": 15000,
"captures": { "screenshotOn": "failure_and_boundary", "html": false, "har": true, "trace": false }
}
],
"assertions": [
{ "id": "health-status", "kind": "outcome", "severity": "critical", "description": "Health returns 200", "definition": { "type": "httpStatus", "stepId": "health-get", "status": 200 } },
{ "id": "health-body", "kind": "outcome", "severity": "high", "description": "Health body reports ok", "definition": { "type": "jsonPath", "stepId": "health-get", "path": "$.status", "equals": "ok" } },
{ "id": "health-latency", "kind": "outcome", "severity": "medium", "description": "Health responds within 2s", "definition": { "type": "latencyBelow", "stepId": "health-get", "ms": 2000 } }
],
"schedule": { "kind": "interval", "everySeconds": 60 },
"materiality": { "consecutiveFailures": 2, "corroboratingLocations": 1, "windowSeconds": 1800, "autoResolveAfterPasses": 2, "behaviourOpensEvent": false },
"locationPolicy": { "classes": ["edge"], "minLocations": 1 },
"notifications": { "channels": [], "onOpen": true, "onResolve": true, "onBehaviourChange": false, "minSeverity": "low" },
"costClass": "http",
"requiredEvidence": ["response", "har"]
}
Validation errors
A definition that fails validation is rejected with a 400 problem+json response of type https://api.scoutsentinel.com/problems/validation_failed whose errors[] lists each issue as { path, code, message }, for example probes.0.steps.3.type: step type "dns.resolve" is not allowed in a "browser" probe or assertions.2.definition.stepId: assertion "api-status" references unknown step "post-quotes". The evaluator version and probe version are recorded on every Observation so results stay comparable across upgrades.