$npx -y skills add arimanyus/hermes-merchant --skill browser-harness-ats-automationAutomate job applications on ATS platforms (Ashby, Greenhouse, Workday) using browser-use/browser-harness with CDP. Covers iframe session management, file upload, hidden checkbox handling, and known reCAPTCHA/S3 blockers.
| 1 | # browser-harness-ats-automation |
| 2 | |
| 3 | Automate job applications on ATS platforms (Ashby, Greenhouse, Workday) using [browser-use/browser-harness](https://github.com/browser-use/browser-harness) with CDP. |
| 4 | |
| 5 | ## Critical architecture: ATS forms live in iframes |
| 6 | |
| 7 | Modern ATS platforms (Ashby, Greenhouse) render application forms inside **cross-origin iframes**. This is the root cause of most automation failures. You MUST attach to the iframe session before any DOM operations. |
| 8 | |
| 9 | ### Standard setup |
| 10 | |
| 11 | ```bash |
| 12 | # Start Xvfb + Chrome with remote debugging |
| 13 | Xvfb :99 -screen 0 1280x800x24 & |
| 14 | export DISPLAY=:99 |
| 15 | /usr/bin/chromium-browser --headless --no-sandbox --disable-gpu \ |
| 16 | --remote-debugging-port=9222 \ |
| 17 | --remote-debugging-address=127.0.0.1 & |
| 18 | sleep 3 |
| 19 | |
| 20 | # Install browser-harness |
| 21 | git clone https://github.com/browser-use/browser-harness.git |
| 22 | cd browser-harness && uv sync && uv tool install -e . |
| 23 | |
| 24 | # Start daemon |
| 25 | cd browser-harness && nohup uv run bu-daemon & |
| 26 | sleep 2 |
| 27 | ``` |
| 28 | |
| 29 | ### CDP socket pattern |
| 30 | |
| 31 | The socket is at `/tmp/bu-default.sock`. Use this helper inside `uv run browser-harness <<'PY'`: |
| 32 | |
| 33 | ```python |
| 34 | import socket, json |
| 35 | |
| 36 | def cdp(method, session_id=None, **params): |
| 37 | sk = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) |
| 38 | sk.connect("/tmp/bu-default.sock") |
| 39 | r = {"method": method, "params": {"session_id": session_id, **params} if session_id else params} |
| 40 | sk.sendall((json.dumps(r) + "\n").encode()) |
| 41 | data = b"" |
| 42 | while not data.endswith(b"\n"): |
| 43 | chunk = sk.recv(1 << 20) |
| 44 | if not chunk: break |
| 45 | data += chunk |
| 46 | sk.close() |
| 47 | return json.loads(data).get("result", {}) |
| 48 | |
| 49 | FRAME_ID = "DFF978A387F9E4D8D8A8AF1EF7385A08" |
| 50 | sid = cdp("Target.attachToTarget", targetId=FRAME_ID, flatten=True).get("sessionId") |
| 51 | ``` |
| 52 | |
| 53 | ### Finding the right iframe target |
| 54 | |
| 55 | ```python |
| 56 | targets = cdp("Target.getTargets") |
| 57 | for t in targets.get("result", {}).get("targetInfos", []): |
| 58 | if t["type"] in ("page", "iframe"): |
| 59 | print(f" {t['type']}: {t.get('url','')[:80]} id={t['targetId']}") |
| 60 | ``` |
| 61 | |
| 62 | ### File upload in iframes |
| 63 | |
| 64 | CSS selectors (`querySelector`) often fail inside iframes. Walk the DOM tree instead: |
| 65 | |
| 66 | ```python |
| 67 | def find_file_inputs_in_frame(session_id): |
| 68 | doc = cdp("DOM.getDocument", session_id=session_id, depth=-1) |
| 69 | queue = [doc["root"]] |
| 70 | found = [] |
| 71 | while queue: |
| 72 | node = queue.pop(0) |
| 73 | al = node.get("attributes", []) |
| 74 | attrs = {} |
| 75 | for i in range(0, len(al) - 1, 2): |
| 76 | attrs[al[i]] = al[i + 1] |
| 77 | if node.get("nodeName") == "INPUT" and attrs.get("type") == "file": |
| 78 | found.append({"nodeId": node["nodeId"], "name": attrs.get("name", "")}) |
| 79 | queue.extend(node.get("children", [])) |
| 80 | return found |
| 81 | |
| 82 | # Load resume path from profile.yaml |
| 83 | import yaml, os |
| 84 | from pathlib import Path |
| 85 | cfg = yaml.safe_load(Path("profile.yaml").read_text()) |
| 86 | resume_path = os.path.expanduser(cfg["resume"]["path"]) |
| 87 | |
| 88 | for inp in find_file_inputs_in_frame(sid): |
| 89 | if 'resume' in inp['name'].lower() or inp['name'] == '_systemfield_resume': |
| 90 | cdp("DOM.setFileInputFiles", session_id=sid, |
| 91 | files=[resume_path], nodeId=inp["nodeId"]) |
| 92 | ``` |
| 93 | |
| 94 | ### Hidden work-authorization checkboxes (Ashby) |
| 95 | |
| 96 | Ashby uses hidden checkboxes at `y=0` in the DOM, controlled by visible Yes/No button overlays. Use CDP mouse clicks in the frame session: |
| 97 | |
| 98 | ```python |
| 99 | # Work auth "No" — coordinates will differ per viewport; take a screenshot to confirm. |
| 100 | cdp("Input.dispatchMouseEvent", session_id=sid, type="mousePressed", |
| 101 | x=348, y=1282, button="left", clickCount=1) |
| 102 | cdp("Input.dispatchMouseEvent", session_id=sid, type="mouseReleased", |
| 103 | x=348, y=1282, button="left", clickCount=1) |
| 104 | |
| 105 | # Visa "No" |
| 106 | cdp("Input.dispatchMouseEvent", session_id=sid, type="mousePressed", |
| 107 | x=348, y=1415, button="left", clickCount=1) |
| 108 | cdp("Input.dispatchMouseEvent", session_id=sid, type="mouseReleased", |
| 109 | x=348, y=1415, button="left", clickCount=1) |
| 110 | ``` |
| 111 | |
| 112 | Answer selection is driven by `cfg["work_authorization"]` from `profile.yaml`. |
| 113 | |
| 114 | ### Submit and intercept server response |
| 115 | |
| 116 | ```python |
| 117 | cdp("Runtime.evaluate", session_id=sid, expression=""" |
| 118 | (function() { |
| 119 | window._gqlResponse = null; |
| 120 | const origFetch = window.fetch; |
| 121 | window.fetch = function(req) { |
| 122 | const url = typeof req === 'string' ? req : req.url; |
| 123 | if (url && url.includes('non-user-graphql')) { |
| 124 | return origFetch.apply(this, arguments).then(async r => { |
| 125 | const clone = r.clone(); |
| 126 | const text = await clone.text(); |
| 127 | window._gqlResponse = {status: r.status, body: text}; |
| 128 | return r; |
| 129 | }); |
| 130 | } |
| 131 | return origFetch.apply(this, arguments); |
| 132 | }; |
| 133 | })() |
| 134 | """, returnByValue=True, awaitPromise=True) |
| 135 | |
| 136 | cdp("Runtime.evaluate", session_id=sid, |
| 137 | expression="(function(){const b=Array.from(document. |