A task never writes on a site by itself. It proposes an action, a person approves that exact action, and only then does the runner perform it, without your code or the model.
The read-only guard
While a run prepares, the runner holds its own DevTools connection to the browser, attached to every page, frame and worker. It fails every request whose method is not GET, HEAD or OPTIONS, and logs it as a blocked event (counted in the run's blocked_requests). Your script and the agent cannot turn it off: it is a separate connection they never see.
A form submission, a fetch with POST or a DELETE from the page all fail in this phase. Pages that need a POST just to be read do not work in a task.
Steps
An action is a list of 1 to 50 typed steps. The first one must be goto. Selectors are Playwright selectors (css=, text=, role=…; CSS pierces open shadow roots).
op | Fields | What it does |
|---|---|---|
goto | url | Open an https:// URL on one of the task's allowed domains |
click | selector | Click the element |
fill | selector, text | Set the value of an input or text area |
type | selector, text | Type the text key by key |
press | key, optional selector | Press a key, for example Enter |
select | selector, value | Choose an option of a <select> |
check | selector | Tick a checkbox or radio button |
wait_for | selector or text | Wait until it appears |
wait | ms | Wait 1 to 10000 ms |
assert_url | url | Stop with an error unless the page URL starts with it (https://, allowed domain) |
assert_text | text | Stop with an error unless the page shows it |
Texts are up to 10000 characters, selectors and values up to 2000, URLs up to 4000. A proposal also carries a one-line summary (up to 300 characters), the target (the https:// URL the write lands on; its host is the action's domain) and a screenshot of the page as the task saw it.
Use assert_url and assert_text so an action fails rather than doing something else when the page has changed since it was proposed.
The payload hash
OpenWAF validates the steps against the task's allowed domains and stores payload_sha256: the hex SHA-256 of the canonical JSON of {"steps": [...]}, with fields in the order of the table above (op, url, selector, text, key, value, ms), empty fields left out, no HTML escaping and no trailing newline.
To approve, you send that hash back with POST /v1/orgs/{org}/task-approvals/{id}/approve. If it differs from the stored one the answer is 409 payload_changed: you approve what you saw, nothing else. The runner recomputes the hash again before acting.
The act phase
After approval the runner starts a new browser with the task's key and profile, checks the hash and replays the steps itself. Neither your script nor the model runs. Requests with write methods are allowed only to the action's domain and its subdomains. The approval gets executed_at, a final screenshot and a result: ok, final_url and error.
If a per-domain limit does not allow the action yet, the approval stays approved with not_before and runs then.
States
| Approval | Meaning |
|---|---|
pending | Waiting for a person |
approved | Approved; waiting for the runner or for not_before |
executed | The steps ran to the end |
failed | A step failed; result.error says which |
rejected | A person rejected it |
expired | Nobody decided in time |
A run with proposals stays awaiting_approval until none is pending. Then it ends succeeded if an action ran, failed if one failed, rejected if all were rejected or expired if they expired.
Expiry
An approval expires after approval.expire_minutes: 120 minutes (2 hours) by default, between 10 and 1440.
Policies
approval.policy | Meaning |
|---|---|
required | Every action waits for a person. The only policy you can set. |
relaxed | Proposals to relaxed_domains are approved automatically. Only an operator sets it, with a reason, and only for internal, non-public targets. |
Webhook
Give the task a webhook.url (a public https:// URL) to get approval.requested and run.finished events. The secret (owts_…) is shown once when you set the webhook; "rotate_webhook_secret": true in a PATCH makes a new one.
Each delivery is a POST with a JSON body and these headers:
X-OpenWAF-Signature: t=1761735600,v1=5d2f…
X-OpenWAF-Event: approval.requested
X-OpenWAF-Delivery: 0b9d5c3e-…{"id": "0b9d5c3e-…", "type": "approval.requested", "created_at": "2026-10-29T10:00:00Z", "org_id": "…",
"task": {"id": "…", "name": "guestbook"},
"run": {"id": "…", "number": 12, "status": "awaiting_approval", "error": "", "finished_at": null},
"approval": {"id": "…", "status": "pending", "summary": "Sign the guestbook…", "target": "https://example.com/guestbook",
"domain": "example.com", "payload_sha256": "9c1e…", "expires_at": "2026-10-29T12:00:00Z"}}approval is null in run.finished. A 2xx answer counts as delivered; otherwise the delivery is retried 3 times, after 1, 5 and 30 minutes. X-OpenWAF-Delivery stays the same across retries, so you can drop duplicates.
t is the Unix time of the delivery and v1 the hex HMAC-SHA256, keyed with the secret, of t, a dot and the raw body. Recompute it, compare in constant time and reject deliveries older than 5 minutes:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const want = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const got = Buffer.from(parts.v1 ?? "", "hex");
return got.length === want.length && timingSafeEqual(got, want);
}