# Browser Click Workflow Example Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a deterministic multi-node workflow example that opens a local browser page with a button, captures before/after snapshots around a simulated or human click, and runs through artifact/deployment/run.
**Architecture:** The example is a trusted Python source under `examples/browser_click_workflow/`. It uses a tiny stdlib HTTP server as the page fixture and three serial workflow nodes: `open_click_page -> wait_for_click -> collect_snapshots`. Tests run with `simulate=true` and `open_browser=false`; manual users can set `open_browser=true`.
**Tech Stack:** Python 3.14, `wf_authoring.node`, `wf_config`, `wf_server.config`, stdlib `http.server`, `threading`, `urllib.request`, pytest.
---
## Boundaries
- Do not implement fork/gather.
- Do not depend on Playwright or Playwright MCP for this committed baseline.
- Do not output base64 screenshots. Snapshots are bounded JSON values: title, status text, button text, clicked flag, event log.
- Any background HTTP server started by the source must be shut down by the final workflow node or explicit helper cleanup.
- The later agent-trial harness may use `npx -y @playwright/mcp@latest`; this example must remain deterministic without it.
## File Structure
- Create `examples/browser_click_workflow/ops.py` — Python source with the three `@node` capabilities and stdlib local page session manager.
- Create `examples/browser_click_workflow/wf.config.json` — local workflow config with `local.browser_click` Python source and RPC transport on an unused example port.
- Create `examples/browser_click_workflow/run-input.json` — deterministic run input using simulated click and no browser popup.
- Create `examples/browser_click_workflow/README.md` — runbook and expected output.
- Create `tests/examples/test_browser_click_workflow_example.py` — helper tests and full artifact/deployment/run lifecycle test.
- Modify `docs/add/system-design-implementation.md` — reference this as optional richer multi-node evidence, without replacing the deterministic report case study.
- Modify `docs/add/evidence-index.md` — add browser click example as supplemental lifecycle/graph evidence.
- Modify `docs/project_map.md` — list the new example.
- Modify `docs/current_roadmap.md` — mark browser-click example completed.
---
### Task 1: Add Browser Click Python Source
**Files:**
- Create: `examples/browser_click_workflow/ops.py`
- Test: `tests/examples/test_browser_click_workflow_example.py`
- [ ] **Step 1: Create failing helper tests**
Create `tests/examples/test_browser_click_workflow_example.py` with:
```python
from __future__ import annotations
import pytest
from examples.browser_click_workflow.ops import (
CollectSnapshotsInput,
OpenPageInput,
WaitForClickInput,
_active_session_count,
_collect_snapshots,
_open_click_page,
_wait_for_click,
)
def test_browser_click_source_simulates_click_and_cleans_up() -> None:
opened = _open_click_page(
OpenPageInput(button_label="Launch Workflow", open_browser=False)
)
assert opened.before.clicked is False
assert opened.before.button_text == "Launch Workflow"
assert opened.before.status_text == "Waiting for click"
clicked = _wait_for_click(
WaitForClickInput(session_id=opened.session_id, simulate=True, timeout_seconds=2)
)
result = _collect_snapshots(
CollectSnapshotsInput(
session_id=opened.session_id,
before=opened.before,
after=clicked.after,
)
)
assert result.before.clicked is False
assert result.after.clicked is True
assert result.after.status_text == "Button clicked"
assert result.closed is True
assert _active_session_count() == 0
def test_browser_click_source_human_timeout_cleans_up() -> None:
opened = _open_click_page(OpenPageInput(open_browser=False))
with pytest.raises(TimeoutError, match="timed out waiting for click"):
_wait_for_click(
WaitForClickInput(
session_id=opened.session_id,
simulate=False,
timeout_seconds=0.01,
)
)
_collect_snapshots(
CollectSnapshotsInput(
session_id=opened.session_id,
before=opened.before,
after=opened.before,
)
)
assert _active_session_count() == 0
```
- [ ] **Step 2: Run tests to verify failure**
Run:
```powershell
uv run pytest tests/examples/test_browser_click_workflow_example.py -q
```
Expected: fail because `examples.browser_click_workflow.ops` does not exist.
- [ ] **Step 3: Implement `ops.py`**
Create `examples/browser_click_workflow/ops.py`:
```python
from __future__ import annotations
import json
import threading
import uuid
import webbrowser
from dataclasses import dataclass
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import ClassVar
from urllib.request import Request, urlopen
from pydantic import BaseModel, Field
from wf_authoring import node
class Snapshot(BaseModel):
title: str
url: str
button_text: str
status_text: str
clicked: bool
events: list[str] = Field(default_factory=list, max_length=10)
class OpenPageInput(BaseModel):
button_label: str = "Click to continue"
open_browser: bool = Field(
default=False,
description="Open the page in the default browser for manual runs.",
)
class OpenPageOutput(BaseModel):
session_id: str
url: str
before: Snapshot
class WaitForClickInput(BaseModel):
session_id: str
simulate: bool = Field(
default=True,
description="When true, perform a deterministic HTTP click instead of waiting.",
)
timeout_seconds: float = Field(default=10.0, gt=0)
class WaitForClickOutput(BaseModel):
clicked: bool
after: Snapshot
class CollectSnapshotsInput(BaseModel):
session_id: str
before: Snapshot
after: Snapshot
class CollectSnapshotsOutput(BaseModel):
before: Snapshot
after: Snapshot
closed: bool
@dataclass(slots=True)
class _ClickSession:
session_id: str
button_label: str
server: ThreadingHTTPServer
thread: threading.Thread
clicked: threading.Event
events: list[str]
@property
def url(self) -> str:
host, port = self.server.server_address
return f"http://{host}:{port}/"
def snapshot(self) -> Snapshot:
clicked = self.clicked.is_set()
return Snapshot(
title="Workflow Click Fixture",
url=self.url,
button_text=self.button_label,
status_text="Button clicked" if clicked else "Waiting for click",
clicked=clicked,
events=list(self.events[-10:]),
)
def close(self) -> None:
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
_SESSIONS: dict[str, _ClickSession] = {}
_SESSIONS_LOCK = threading.Lock()
class _ClickHandler(BaseHTTPRequestHandler):
server: _ClickServer
def log_message(self, _format: str, *_args: object) -> None:
"""Keep example runs quiet; workflow trace carries the useful output."""
def do_GET(self) -> None:
if self.path == "/":
self._send_html()
return
if self.path == "/snapshot":
self._send_json(self.server.session.snapshot().model_dump(mode="json"))
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
if self.path != "/click":
self.send_error(HTTPStatus.NOT_FOUND)
return
session = self.server.session
session.clicked.set()
session.events.append("click")
self._send_json(session.snapshot().model_dump(mode="json"))
def _send_html(self) -> None:
session = self.server.session
status = "Button clicked" if session.clicked.is_set() else "Waiting for click"
html = f"""
Workflow Click Fixture
Workflow Click Fixture
{status}
"""
encoded = html.encode("utf-8")
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def _send_json(self, payload: dict[str, object]) -> None:
encoded = json.dumps(payload).encode("utf-8")
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
class _ClickServer(ThreadingHTTPServer):
session: _ClickSession
allow_reuse_address: ClassVar[bool] = True
@node(name="open_click_page")
def open_click_page(payload: OpenPageInput) -> OpenPageOutput:
return _open_click_page(payload)
@node(name="wait_for_click")
def wait_for_click(payload: WaitForClickInput) -> WaitForClickOutput:
return _wait_for_click(payload)
@node(name="collect_snapshots")
def collect_snapshots(payload: CollectSnapshotsInput) -> CollectSnapshotsOutput:
return _collect_snapshots(payload)
def _open_click_page(payload: OpenPageInput) -> OpenPageOutput:
session_id = f"click_{uuid.uuid4().hex}"
server = _ClickServer(("127.0.0.1", 0), _ClickHandler)
session = _ClickSession(
session_id=session_id,
button_label=payload.button_label,
server=server,
thread=threading.Thread(target=server.serve_forever, daemon=True),
clicked=threading.Event(),
events=["opened"],
)
server.session = session
with _SESSIONS_LOCK:
_SESSIONS[session_id] = session
session.thread.start()
if payload.open_browser:
webbrowser.open(session.url)
return OpenPageOutput(
session_id=session_id,
url=session.url,
before=session.snapshot(),
)
def _wait_for_click(payload: WaitForClickInput) -> WaitForClickOutput:
session = _get_session(payload.session_id)
if payload.simulate:
request = Request(f"{session.url}click", method="POST")
with urlopen(request, timeout=payload.timeout_seconds) as response:
response.read()
elif not session.clicked.wait(timeout=payload.timeout_seconds):
raise TimeoutError("timed out waiting for click")
return WaitForClickOutput(clicked=session.clicked.is_set(), after=session.snapshot())
def _collect_snapshots(payload: CollectSnapshotsInput) -> CollectSnapshotsOutput:
session = _get_session(payload.session_id)
session.close()
with _SESSIONS_LOCK:
_SESSIONS.pop(payload.session_id, None)
return CollectSnapshotsOutput(before=payload.before, after=payload.after, closed=True)
def _get_session(session_id: str) -> _ClickSession:
with _SESSIONS_LOCK:
session = _SESSIONS.get(session_id)
if session is None:
raise ValueError(f"unknown click session {session_id!r}")
return session
def _active_session_count() -> int:
with _SESSIONS_LOCK:
return len(_SESSIONS)
registry = [open_click_page, wait_for_click, collect_snapshots]
```
- [ ] **Step 4: Run helper tests**
Run:
```powershell
uv run pytest tests/examples/test_browser_click_workflow_example.py::test_browser_click_source_simulates_click_and_cleans_up tests/examples/test_browser_click_workflow_example.py::test_browser_click_source_human_timeout_cleans_up -q
```
Expected: both tests pass.
- [ ] **Step 5: Commit**
```powershell
git add examples/browser_click_workflow/ops.py tests/examples/test_browser_click_workflow_example.py
git commit -m "feat: add browser click source example"
```
---
### Task 2: Add Config, Input, And Multi-Node Lifecycle Test
**Files:**
- Create: `examples/browser_click_workflow/wf.config.json`
- Create: `examples/browser_click_workflow/run-input.json`
- Modify: `tests/examples/test_browser_click_workflow_example.py`
- [ ] **Step 1: Create config**
Create `examples/browser_click_workflow/wf.config.json`:
```json
{
"version": 1,
"client": {
"target": {
"kind": "rpc_http",
"url": "http://127.0.0.1:8772/rpc",
"timeout_seconds": 30
}
},
"server": {
"store": {
"kind": "filesystem",
"root": ".wf_browser_click_store"
},
"transports": [
{
"kind": "rpc_http",
"host": "127.0.0.1",
"port": 8772,
"path": "/rpc"
}
],
"sources": [
{
"kind": "python",
"id": "local.browser_click",
"path": ".",
"module": "ops",
"registry": "registry"
}
]
}
}
```
- [ ] **Step 2: Create deterministic run input**
Create `examples/browser_click_workflow/run-input.json`:
```json
{
"button_label": "Launch Workflow",
"open_browser": false,
"simulate": true,
"timeout_seconds": 2
}
```
- [ ] **Step 3: Add full lifecycle test**
Append to `tests/examples/test_browser_click_workflow_example.py`:
```python
from pathlib import Path
from wf_config import load_workflow_config
from wf_server.config import build_workflow_server_from_workflow_config
EXAMPLE_DIR = Path(__file__).resolve().parents[2] / "examples" / "browser_click_workflow"
async def test_browser_click_workflow_artifact_deployment_run_path(tmp_path) -> None:
config = load_workflow_config(EXAMPLE_DIR / "wf.config.json")
config.server.store.root = tmp_path / "store"
server = build_workflow_server_from_workflow_config(config)
plan = {
"name": "browser_click_case_study",
"input_schema": {
"type": "object",
"properties": {
"button_label": {"type": "string"},
"open_browser": {"type": "boolean"},
"simulate": {"type": "boolean"},
"timeout_seconds": {"type": "number"},
},
"required": ["button_label", "open_browser", "simulate", "timeout_seconds"],
},
"state_schema": {
"type": "object",
"properties": {
"opened": {"type": "object", "reducer": "wf.std.replace"},
"clicked": {"type": "object", "reducer": "wf.std.replace"},
"result": {"type": "object", "reducer": "wf.std.replace"},
},
},
"output_schema": {
"type": "object",
"properties": {
"before": {"type": "object"},
"after": {"type": "object"},
"closed": {"type": "boolean"},
},
"required": ["before", "after", "closed"],
},
"outcomes": ["ok"],
"start": "open",
"nodes": [
{
"id": "open",
"type": "node",
"node": "local.browser_click.open_click_page",
"input": [
{
"path": {"root": "input", "parts": ["button_label"]},
"target": {"root": "local", "parts": ["button_label"]},
},
{
"path": {"root": "input", "parts": ["open_browser"]},
"target": {"root": "local", "parts": ["open_browser"]},
},
],
"output": [
{
"source": {"root": "local", "parts": []},
"target": {"root": "state", "parts": ["opened"]},
}
],
},
{
"id": "wait",
"type": "node",
"node": "local.browser_click.wait_for_click",
"input": [
{
"path": {"root": "state", "parts": ["opened", "session_id"]},
"target": {"root": "local", "parts": ["session_id"]},
},
{
"path": {"root": "input", "parts": ["simulate"]},
"target": {"root": "local", "parts": ["simulate"]},
},
{
"path": {"root": "input", "parts": ["timeout_seconds"]},
"target": {"root": "local", "parts": ["timeout_seconds"]},
},
],
"output": [
{
"source": {"root": "local", "parts": []},
"target": {"root": "state", "parts": ["clicked"]},
}
],
},
{
"id": "collect",
"type": "node",
"node": "local.browser_click.collect_snapshots",
"input": [
{
"path": {"root": "state", "parts": ["opened", "session_id"]},
"target": {"root": "local", "parts": ["session_id"]},
},
{
"path": {"root": "state", "parts": ["opened", "before"]},
"target": {"root": "local", "parts": ["before"]},
},
{
"path": {"root": "state", "parts": ["clicked", "after"]},
"target": {"root": "local", "parts": ["after"]},
},
],
"output": [
{
"source": {"root": "local", "parts": []},
"target": {"root": "state", "parts": ["result"]},
}
],
},
],
"edges": [
{"from": "open", "outcome": "ok", "to": "wait"},
{"from": "wait", "outcome": "ok", "to": "collect"},
{"from": "collect", "outcome": "ok", "to": "__end__"},
],
"output": [
{
"path": {"root": "state", "parts": ["result", "before"]},
"target": {"root": "local", "parts": ["before"]},
},
{
"path": {"root": "state", "parts": ["result", "after"]},
"target": {"root": "local", "parts": ["after"]},
},
{
"path": {"root": "state", "parts": ["result", "closed"]},
"target": {"root": "local", "parts": ["closed"]},
},
],
}
await server.api.create_artifact_from_plan(
artifact_id="browser_click_case_study",
version=1,
title="Browser Click Case Study",
plan=plan,
outcomes=["ok"],
source_bindings={"local.browser_click": "local.browser_click"},
)
await server.api.save_deployment(
{
"id": "browser_click_case_study.default",
"artifact_id": "browser_click_case_study",
"artifact_version": 1,
"bindings": {"local.browser_click": "local.browser_click"},
}
)
run = await server.api.run_deployment(
deployment_id="browser_click_case_study.default",
workflow_input={
"button_label": "Launch Workflow",
"open_browser": False,
"simulate": True,
"timeout_seconds": 2,
},
)
assert run["status"] == "completed"
assert run["output"]["before"]["clicked"] is False
assert run["output"]["after"]["clicked"] is True
assert run["output"]["after"]["status_text"] == "Button clicked"
assert run["output"]["closed"] is True
assert run["trace_count"] >= 3
```
- [ ] **Step 4: Run lifecycle test**
Run:
```powershell
uv run pytest tests/examples/test_browser_click_workflow_example.py -q
```
Expected: all browser-click tests pass.
- [ ] **Step 5: Validate config**
Run:
```powershell
uv run wf config validate examples/browser_click_workflow/wf.config.json
```
Expected: valid config output.
- [ ] **Step 6: Commit**
```powershell
git add examples/browser_click_workflow/wf.config.json examples/browser_click_workflow/run-input.json tests/examples/test_browser_click_workflow_example.py
git commit -m "test: prove browser click workflow lifecycle"
```
---
### Task 3: Add Runbook And Documentation Links
**Files:**
- Create: `examples/browser_click_workflow/README.md`
- Modify: `docs/add/system-design-implementation.md`
- Modify: `docs/add/evidence-index.md`
- Modify: `docs/project_map.md`
- Modify: `docs/current_roadmap.md`
- [ ] **Step 1: Create README**
Create `examples/browser_click_workflow/README.md`:
````markdown
# Browser Click Workflow Example
This example demonstrates a serial multi-node workflow over a trusted Python
source:
```text
open_click_page -> wait_for_click -> collect_snapshots
```
It opens a local HTML page with one visible button, captures a bounded JSON
snapshot before the click, performs a deterministic simulated click by default,
captures an after snapshot, closes the local server, and returns both snapshots
as workflow output.
The example deliberately avoids browser screenshots and base64 payloads. The
snapshots are small JSON objects suitable for CLI and LLM-agent output.
## Run
From the repository root:
```powershell
uv run wf config validate examples/browser_click_workflow/wf.config.json
uv run wf-rpc-server --config examples/browser_click_workflow/wf.config.json
```
In another terminal:
```powershell
uv run wf --config examples/browser_click_workflow/wf.config.json status
uv run wf --config examples/browser_click_workflow/wf.config.json cap list --source local.browser_click
```
The full artifact/deployment/run lifecycle is covered by:
```powershell
uv run pytest tests/examples/test_browser_click_workflow_example.py -q
```
## Manual Browser Mode
The checked-in test uses `"simulate": true` and `"open_browser": false`.
For manual experimentation, set `"open_browser": true` in the run input and
use `"simulate": false`; then click the button before `timeout_seconds` elapses.
Any browser or local server opened by the source is closed by the final
`collect_snapshots` node.
````
- [ ] **Step 2: Link docs**
Patch docs:
- In `docs/project_map.md`, add `examples/browser_click_workflow/` to the examples list as the serial browser-click workflow example.
- In `docs/add/evidence-index.md`, add a supplemental evidence bullet under Python/source or workflow lifecycle evidence:
```markdown
- `examples/browser_click_workflow/`
- `tests/examples/test_browser_click_workflow_example.py`
```
- In `docs/add/system-design-implementation.md`, add one sentence after the report workflow case-study scope paragraph:
```markdown
A supplemental browser-click example demonstrates a serial three-node workflow
with bounded before/after snapshots; it is supporting evidence, not the main
case study.
```
- In `docs/current_roadmap.md`, add a short completed milestone:
```markdown
- Completed supplemental browser-click workflow example with serial multi-node lifecycle evidence.
```
- [ ] **Step 3: Run docs checks**
Run:
```powershell
uv run pytest tests/docs -q
```
Expected: pass.
- [ ] **Step 4: Run focused verification**
Run:
```powershell
uv run pytest tests/examples/test_browser_click_workflow_example.py tests/docs -q
uv run ruff check examples/browser_click_workflow tests/examples/test_browser_click_workflow_example.py
uv run basedpyright --level error examples/browser_click_workflow tests/examples/test_browser_click_workflow_example.py
git diff --check
```
Expected:
- Tests pass.
- Ruff has no errors.
- Basedpyright has no errors.
- `git diff --check` reports no whitespace errors except acceptable CRLF warnings on Windows.
- [ ] **Step 5: Commit**
```powershell
git add examples/browser_click_workflow docs/add/system-design-implementation.md docs/add/evidence-index.md docs/project_map.md docs/current_roadmap.md tests/examples/test_browser_click_workflow_example.py
git commit -m "docs: add browser click workflow example"
```
---
## Self-Review Notes
- The example is serial multi-node, not fork/gather.
- The source closes its server in `collect_snapshots`.
- The test uses simulated click and does not open the user's browser.
- The README warns about manual browser mode and cleanup.
- Playwright MCP is intentionally excluded from this baseline; it belongs in a later agent-trial harness plan.