等待、悬停和滚动可使用受运行 Seed 控制的统一随机范围;鼠标、点击、输入和滚动节奏由 interactionProfiles 配置。字段与示例见 RANDOMIZED_INTERACTIONS.md。
Every workflow has apiVersion, kind, metadata, and a spec:
apiVersion: browserweave.io/v1alpha1
kind: Workflow
metadata:
name: example
spec:
inputs: {}
outputs: {}
hooks: {}
listeners: []
webhooks: []
nodes: {}
edges: []Inputs support string, number, integer, boolean, object, and array. Strings additionally support URI validation, length constraints, enumeration, defaults, and secret redaction. A value originating from a secret: true input stays classified when copied into vars, steps, outputs, child-workflow outputs, or error text. Public snapshots and events replace exact and embedded occurrences with [REDACTED]; raw values remain available only inside the executor, and v0.1 does not provide a declassification escape hatch.
Template namespaces:
${inputs.name}— immutable run input${vars.name}— mutable run variable${steps.nodeId.value}— completed node output${event.name}— current callback event${run.id}— run metadata
An exact template preserves the original value type. A template embedded inside other text is converted to a string.
Concise semantic locator:
locator:
engine: js
role: button
name: Submit
exact: trueCustom JavaScript locator with arguments:
locator:
engine: js
script: |
function locate(args) {
return [...document.querySelectorAll(args.selector)]
.find(element => element.innerText.includes(args.text));
}
args:
selector: article button
text: "${inputs.buttonText}"Arguments are serialized separately. They are not interpolated into JavaScript source.
Weighted candidates:
locator:
engine: js
candidates:
- { type: testId, value: submit-comment, score: 100 }
- { type: role, role: button, name: Submit, exact: true, score: 95 }
- { type: attribute, tag: button, attributes: { type: submit }, score: 80 }
- { type: css, value: "form button[type=submit]", score: 40 }| Type | Main with fields |
Output |
|---|---|---|
start |
none | empty |
end |
none | empty |
navigate |
url |
URL |
page.reload |
optional ignoreCache |
refreshed active tab |
page.back / page.forward |
none | active history entry ID, URL, and title |
tab.open |
url |
new active tab ID, URL, and title |
tab.switch |
urlContains and/or titleContains |
found switches to the unique tab; missing means zero matches; multiple matches remain a failure |
tab.list |
optional saveAs |
all open page targets without switching |
tab.close |
optional urlContains/titleContains |
closed or missing; omitted selector closes the active tab |
dialog.handle |
action, optional type/message/prompt filters and timeout |
handled alert/confirm/prompt metadata |
click |
locator |
resolved element metadata; target-width/Fitts timing, minimum-jerk CDP path, hit-test before press |
hover |
locator |
resolved element metadata; target-aware minimum-jerk CDP path |
drag.drop |
source, target, optional hold |
safe source and target metadata |
scroll |
deltaX and/or deltaY, optional locator |
applied logical deltas; accelerated wheel frames, bounded jitter and corrected overshoot; a locator targets a nested scroll container, while omission scrolls the page |
fill |
locator, value, optional clear |
element and input length; physical ASCII keys, IME-like Unicode commits and bounded pauses |
select |
locator, values, optional mode (value or label) |
element and selected native option values |
check |
locator, strict boolean checked |
element, final state, and whether a click changed it |
press |
locator, keys, optional repeat |
element, chord request, and repeat count |
upload |
locator, files (absolute browser-host paths) |
safe element metadata, file count, and whether the input accepts multiple files |
dom.check |
`mode: any | all, 1–64 declarative conditions, optional negate/saveAs` |
extract |
locator, property, saveAs |
extracted value |
page.extract |
optional locator, format, maxChars, saveAs |
bounded content in vars; only safe metadata in step output |
extract.list |
optional root, literal itemSelector, 1–32 fields, bounds, saveAs |
structured item array in vars; only count/truncation metadata in step output |
model.invoke |
model connection, operation, arbitrary typed input, optional structured output, saveAs |
text or typed JSON value |
wait |
duration or DOM locator/state/text/stableFor |
matched element |
network.wait |
match, timeout, optional handler/saveAs |
HTTP exchange |
download.prepare |
absolute browser-host path, optional namingMode |
starts a clean run-scoped download observation window |
download.wait |
optional URL/file filters, timeout, saveAs |
completed download metadata and final path when CDP supplies it |
cookie.get / cookie.set / cookie.delete |
cookie name/value and optional URL/domain/path scope | browser cookie data or mutation status; set defaults to the active page URL and / path |
storage.get / storage.set / storage.remove / storage.clear |
`scope: local | session, key/value and optional saveAs` |
set |
values |
applied values |
data.emit |
literal channel, optional key, arbitrary value, optional saveAs/sensitive |
DataEmitted intermediate result plus normal step output |
hook |
hook, optional event |
hook result in state |
workflow.call |
literal workflowId, positive integer version, inputs, optional saveAs; browser must be inherit |
pinned child identity and declared outputs |
branch |
JavaScript expression using ctx |
true or false port |
condition |
mode, declarative conditions, optional negate/saveAs |
true or false port and safe match metadata |
evaluate |
JavaScript expression using ctx, optional saveAs/timeout |
evaluated value |
screenshot |
optional path, fullPage |
absolute artifact path |
webhook |
url, body, headers, timeout |
HTTP status |
http.request |
URL, method, headers/body, expected statuses, bounds, optional saveAs |
status, headers, decoded JSON or text body, final URL |
Extract properties in v0.1 are innerText, textContent, value, href, html, and attribute:name.
Finding an existing page and opening a fallback are two explicit actions. A missing page is a normal decision, not a generic failure:
nodes:
select_page:
type: tab.switch
with: {urlContains: yuanbao.tencent.com, titleContains: 元宝}
open_page:
type: tab.open
with: {url: "${inputs.pageUrl}"}
continue_flow: {type: wait, with: {duration: 500ms}}
edges:
- {from: select_page, port: found, to: continue_flow}
- {from: select_page, port: missing, to: open_page}
- {from: open_page, port: success, to: continue_flow}tab.switch never turns ambiguous matches or CDP errors into missing, so the
fallback cannot silently open a duplicate page. When it is the first operation,
Core uses a disposable blank target only to establish the attached CDP session;
the blank target is closed after either branch activates the real page.
Downloads use an explicit three-step sequence: download.prepare, the action
that triggers the download, then download.wait. Preparing first is required
because CDP download events cannot be reconstructed after they have passed.
http.request runs in Core and intentionally does not copy cookies from the
attached browser; pass an explicit header only when the workflow requires one.
fill.with.value keeps accepting ordinary strings and FlowContext templates.
Studio can also persist an explicit source so authors do not need to hand-write
templates or add a separate extraction node:
# A declared workflow input.
value: { source: input, input: comment }
# An upstream extraction, calculation, listener, or model result.
value: { source: context, path: vars.modelResult }
# Read another element immediately before filling the target.
value:
source: element
locator: { css: "#source-field" }
property: valueText sources may still compose multiple bindings, for example
你好,${ctx.inputs.name}:${ctx.vars.modelResult}. Numbers and booleans are
formatted as text; objects and arrays are encoded as JSON. Resolved fill
content is not copied into node output or runtime events.
All three actions use a current element locator; none accepts or persists coordinates. See Form actions for the exact contracts, failure cases, recording behavior, and CDP security boundary.
spec.guards handles unexpected page state without adding fake branches to the
main graph. A detector observes the page in parallel, but it is read-only. When
matched, the scheduler pauses at a node safe point, gives the guard handler
exclusive browser-action ownership, and resumes the main graph afterward.
guards:
- id: verification
name: Verification dialog
priority: 100
trigger:
type: dom
locator: { css: ".verification-dialog" }
operator: exists
debounce: 300ms
pollInterval: 300ms
policy:
cooldown: 3s
maxActivations: 3
timeout: 2m
resolveTimeout: 5s
resume: retryInterruptedNode
onFailure: failRun
flow:
nodes:
start: { type: start }
wait_manual:
type: wait
with:
locator: { css: ".verification-dialog" }
state: hidden
timeout: 90s
done: { type: end }
edges:
- { from: start, port: success, to: wait_manual }
- { from: wait_manual, port: success, to: done }The handler graph has an isolated step namespace. During an activation,
templates and hooks can read ctx.interrupt.id, interruptedNode,
activationCount, detectedAt, and steps. A guard never interrupts another
guard. Handler completion alone is not success: Core polls the original trigger
for up to resolveTimeout and emits GuardFailed when it still matches.
Cooldown, activation limits, and handler timeout bound repeated dialogs.
The resume policy is an explicit continuation choice:
retryInterruptedNoderetries an action that failed behind an overlay.continueresumes after the suspended node.restartWorkflowis for login/session handlers that refresh the whole document. It keeps inputs, the attached browser, network listeners, guard counters, and the same run, but clears main-graph vars, steps, and outputs and resumes at the workflow start node. Lifecycle attachment is not repeated.
Because restartWorkflow can repeat earlier side effects, enable it only when
the business page refresh invalidates all prior page-derived state and keep
maxActivations bounded.
workflow.call reuses an exact immutable published workflow version in the
current browser session:
invoke_login:
type: workflow.call
with:
workflowId: shared-login
version: 7
browser: inherit
inputs:
tenant: "${inputs.tenant}"
username: "${inputs.username}"
saveAs: loginworkflowId and version are literal publication references, not templates.
Input values are resolved in the parent frame and copied into a fresh child
frame. Child inputs, vars, and steps are isolated; only outputs declared
by the child workflow return to the parent. The node output is
{workflowId, version, outputs} and saveAs, when present, stores only the
declared outputs object under parent vars.
The child inherits the already connected browser. It never starts or closes a
browser, and child browser launch/attach fields cannot replace the root
session. browser: new is deliberately unsupported. Exact-reference recursion
is rejected and call depth is limited to 16. A child failure fails the parent
call node and therefore participates in its normal retry/failure policy.
Studio publish and run preparation resolve calls only from immutable published versions; a draft or implicit latest version is never substituted. A standalone Core runner must provide a published-workflow resolver or the call fails explicitly. In v1alpha1 a child call is an opaque debug/event frame: parent breakpoints do not match child node IDs and ordinary child node events are not mixed into the parent graph stream.
Discovery marks this node with executionScopes: [saved]. MCP ad-hoc source
tools workflow_validate and workflow_run intentionally reject it before
browser startup because they do not own a workflow store/resolver; publish the
parent and use workflow_run_saved. Saved Studio/API execution remains fully
supported.
hooks:
normalize:
runtime: javascript
entry: handle
timeout: 500ms
source: |
function handle(ctx) {
return {
patch: {answer: ctx.event.response.body},
output: {received: true},
emit: [{type: "AnswerReceived", data: {ok: true}}],
control: {action: "continue"}
};
}Supported lifecycle references include workflow beforeRun, afterRun, onRunSuccess, onRunFailure, and node beforeNode, beforeLocate, afterLocate, beforeAction, afterAction, onSuccess, onFailure, onRetry.
spec.listeners are workflow-level resources, not graph nodes. Core subscribes
all of them after the browser connects and before the first node starts. Each
listener receives completed HTTP exchanges independently while the main graph
continues executing:
hooks:
normalizeReply:
runtime: javascript
entry: handle
source: |
function handle(ctx) {
const body = JSON.parse(ctx.event.response.body || '{}');
return {patch: {chat: {lastReply: body.answer}}, emit: [{type: 'ReplyCaptured'}]};
}
reportListenerError:
runtime: javascript
entry: handle
source: |
function handle(ctx) { return {emit: [{type: 'ReplyCaptureFailed', data: {message: ctx.event.error}}]}; }
listeners:
- id: chat-replies
match: {url: "**/api/chat/**", method: POST, status: 200, kind: json}
capture: {responseBody: true, maxBytes: 1048576}
saveAs: network.lastChat
onMatch: [normalizeReply]
onError: [reportListenerError]
maxMatches: 20At least one match field is required and listener IDs must be unique.
maxMatches: 0 means unlimited within the current run. Capture is opt-in;
maxBytes defaults to 1 MiB and is capped at 10 MiB. saveAs stores the last
captured exchange. onMatch and onError may patch vars, publish hook output,
and emit custom events. They cannot redirect the graph token because listener
callbacks execute concurrently outside graph control flow. Runtime emits
NetworkMatched, ListenerSucceeded, and ListenerFailed events with the
listener ID for observability.
Prefer saveAs without onMatch or onError: the captured exchange is already
available to later graph nodes. A listener Hook is only needed for a genuine
per-match transform or emitted event. Core parses every Hook before execution;
its entry must name a top-level function in source (entry omitted means
handle). A missing entry is a compile error, so it cannot become a repeated
ListenerFailed event during an SSE stream.
Network matching exposes business response kinds only: json, xhr, and
sse. sse covers native EventSource and Fetch/XHR text/event-stream
responses and matches actual cumulative stream data, not connection headers.
CDP resource types and request lifecycle phases are deliberately not part of
the DSL. If tab.open or tab.switch changes the active tab, the workflow's
parallel listeners move to that tab as part of the same run.
failure:
timeout: 15s
retry:
maxAttempts: 3
backoff: exponential
baseDelay: 400ms
then: goto
target: recoverthen supports goto, continue, or the default abort behavior.
Use evaluate for bounded, side-effect-free data computation without registering a reusable hook:
normalize:
type: evaluate
with:
expression: "ctx.inputs.items.map(item => ({ id: item.id, name: String(item.name).trim() }))"
saveAs: normalizedItems
timeout: 250msThe expression runs in the same isolated Goja runtime as hooks: it has no browser, filesystem, or network host APIs. Its ctx snapshot contains inputs, vars, steps, run, and the current node. The result is emitted as steps.<nodeId>.value; when saveAs is present it is also written under vars.
Use assert when a business invariant should participate in the normal node
failure policy instead of becoming data:
check_result:
type: assert
with:
expression: "ctx.vars.result != null && ctx.vars.result.status === 'ok'"
message: result API did not return an ok payload
timeout: 500ms
failure:
then: continueThe expression must return a strict boolean. false, a timeout, or an exception
fails the node and can retry, abort, jump, or follow its failure port. The
message passes through the same secret-redaction boundary as other execution
errors.
switch evaluates cases in declaration order and follows the first expression
that returns true. If none matches it follows default (or the port named by
with.default). Case/default port names match
[A-Za-z_][A-Za-z0-9_-]{0,31} and a switch supports at most 32 cases.
route:
type: switch
with:
cases:
- {port: paid, expression: "ctx.vars.account.plan === 'paid'"}
- {port: trial, expression: "ctx.vars.account.trial === true"}
default: guest
# edges must declare paid, trial, and guest exactly onceExpressions run in the isolated Goja runtime. A non-boolean case result fails the node instead of applying JavaScript truthiness, which keeps routing configuration explicit and testable.
foreach is a bounded, sequential structured loop. The graph remains acyclic:
the body edge enters the body and a loop.end terminal closes it; done
continues after all items. There is no graph edge back to the foreach node.
eachComment:
type: foreach
with:
items: "ctx.inputs.comments" # must return an array
item: loop.comment # defaults to vars.item
index: loop.index # defaults to vars.index
maxIterations: 100 # defaults to 1000; hard max 10000
collect: "ctx.vars.savedComment" # evaluated after a completed body
saveAs: savedComments # collected array destination
bodyDone:
type: loop.end
with: {loop: eachComment}
edges:
- {from: eachComment, port: body, to: firstBodyNode}
- {from: lastBodyNode, port: success, to: bodyDone}
- {from: eachComment, port: done, to: afterLoop}item and index are scoped variables: previous values are restored when the
loop succeeds or fails. collect is evaluated once after every body that reaches
loop.end, appended in input order, and stored at saveAs. An empty input
stores an empty array and immediately follows done.
saveAs must not equal, contain, or be contained by the item/index paths;
otherwise restoring the iteration aliases could erase the collected result.
The body is owned by its structured loop: no root or sibling edge may enter a
body node, and one body subgraph cannot be shared by multiple foreach/for nodes.
Conditional flow inside a body may route to loop.continue or loop.break;
both require the with.loop field naming their foreach
or for owner and have no outgoing edge.
Neither control terminal evaluates collect for that iteration. Nested foreach
is supported to depth four.
The entire foreach node cannot use failure.retry.maxAttempts > 1: replaying
completed browser actions is unsafe without durable per-iteration checkpoints
and idempotency. Individual nodes inside the body retain their normal retry
policy. Runtime outputs under steps.<foreachId> include total, iterations
(attempted), completed, and collected. Iteration lifecycle events are emitted
for real-time UI/API progress.
for uses the same structured body/done graph, scoped item/index,
collect/saveAs, nesting, break, and continue semantics as foreach. Its
range is computed before the body starts, so an oversized range cannot partially
execute browser actions.
pages:
type: for
with:
from: "ctx.inputs.firstPage" # finite number or Goja expression
to: "ctx.inputs.lastPage"
step: 1 # defaults to 1; must not be zero
inclusive: true # defaults to false
item: page.number # defaults to vars.item
index: page.index # zero-based, defaults to vars.index
maxIterations: 100
collect: "ctx.vars.pageResult"
saveAs: pageResults
pageDone:
type: loop.end
with: {loop: pages} # owner may be foreach or for
edges:
- {from: pages, port: body, to: openPage}
- {from: lastPageBodyNode, port: success, to: pageDone}
- {from: pages, port: done, to: afterPages}Positive and negative steps are supported. A direction/boundary mismatch yields
an empty sequence: for example from: 5, to: 0, step: 1 performs zero
iterations. The default range is end-exclusive; inclusive: true includes to
only when the step lands on it. Bounds and a dynamic step are evaluated in the
isolated Goja runtime and must produce finite numbers. A dynamic zero step fails
before the body starts. Numeric loops emit ForIteration* progress events with
only index/count metadata; item values are intentionally omitted to prevent
secret data from leaking through event streams.
while is a pre-test loop: it evaluates its strict-boolean Goja condition
before every body entry and may execute zero times. until is a post-test
(do-until) loop: it executes the body first and evaluates condition after
normal completion or continue, so it always executes at least once unless the
run is already canceled.
repeat:
type: while # or until
with:
condition: "ctx.vars.pending === true"
maxIterations: 100 # default 1000; hard maximum 10000
timeout: 500ms # default 500ms; allowed 1ms..30s
iteration: loop.iteration # optional counter alias
index: loop.index # defaults to vars.index
collect: "ctx.vars.result"
saveAs: results
repeatDone:
type: loop.end
with: {loop: repeat}
edges:
- {from: repeat, port: body, to: firstBodyNode}
- {from: lastBodyNode, port: success, to: repeatDone}
- {from: repeat, port: done, to: afterRepeat}The condition result must be an actual JavaScript boolean; truthy/falsy
coercion is never applied. The iteration budget counts body entries. A while
whose condition remains true and an until whose condition remains false fail
before body entry maxIterations + 1. Condition errors, timeout, or
cancellation never replay an already completed body.
The default scoped counter is only vars.index, starting at zero. Configure
iteration to expose the same ordinal at another path. Both counters restore
their prior values after success or failure.
loop.continue skips collection. It returns while to the next pre-test and
sends until to the current iteration's post-test. loop.break skips
collection and any later condition, then follows done. Whole-loop retries
above one attempt are invalid because body actions may have side effects.
loop.end with with.loop is the only normal terminal contract for all
structured loop types. Mixed foreach, for, while, and until nesting is
supported to depth four. Removed legacy terminal names and owner fields are
rejected during validation instead of silently normalized.
Condition-loop events contain only loop type, node ID, index, limit, and terminal metadata. They never contain condition source, item values, vars, collected values, or raw JavaScript errors.
page.extract reads bounded text, textContent, or html from
document.body or an optional Locator and writes it to saveAs. Extracted
content is visible by default. Set sensitive: true explicitly only when the
raw value must remain usable inside the executor but hidden from public
snapshots and events.
extract.list queries repeated elements under the document or an optional
unique root Locator. Its literal itemSelector matches each business item;
named fields use an optional relative CSS selector and read innerText,
textContent, value, href, html, or attribute:<name>. Results are
bounded by item count and per-field Unicode characters. Missing children become
null and absolute coordinates are never stored. Structured business fields
are visible by default so FlowContext can be inspected. Set sensitive: true
explicitly for messages, comments, or private records; this masks every
extracted scalar at public boundaries while keeping the typed array available
to foreach and model.invoke.
model.invoke resolves a literal connection through the control plane, sends
templated instructions plus arbitrary JSON-compatible input, and optionally
writes text or a parsed JSON value to saveAs. operation describes generate,
classify, extract, transform, evaluate, or custom computation without adding
hidden prompts. output.type=json accepts a provider-neutral schema and
fails before downstream effects if the output is not valid JSON. A workflow
never carries an endpoint credential. Discovery marks the node saved-only
because Studio owns its model-connection store. The optional model,
temperature, and maxOutputTokens fields override connection defaults.
Use condition to combine typed values with mode=all|any and route through
true/false without JavaScript. Exact ${inputs.*}, ${vars.*}, and
${steps.*} bindings preserve JSON types and can feed action parameters,
loops, subflows, webhooks, or predicates. See Typed value flow
and Page content and model nodes for the full contracts.