Imported from SimonOneNineEight/openclaw (
workspace/.claude/skills/mass-apply/SKILL.md). Install upstream withnpx skills add SimonOneNineEight/openclaw --skill mass-apply. Copyright stays with the author.
Mass Apply
Read this file and all platform guides in
platforms/fresh every time. Do not rely on memory or previous runs.
NEVER refresh a page mid-fill
Most ATS forms wipe ALL fields on refresh. If you hit an error mid-fill, follow this decision tree:
Recoverable (re-snapshot, keep going):
- Element not found / ref mismatch → re-snapshot, find field again
- Dropdown didn't open → try
eval focus() + ArrowDown - Field didn't fill → try
evalwith value setter +dispatchEvent - Timeout on networkidle → wait a few more seconds, re-snapshot
- Wrong value in a field → clear and re-fill
Broken (stop filling this job, move to next):
- Redirected to login page → session expired. Try re-login + navigate back ONCE. If form is wiped, mark
skipped. - Page shows "session expired", "application closed", or error message → screenshot, mark
skipped - Browser tab is blank/crashed → mark
skipped - Redirected away from the form URL → mark
skipped - Same field has failed 3+ times → skip the field, continue with the rest. If it's a required field, mark job as
filled(partially done).
NEVER: Refresh, reload, or navigate away from a half-filled form. Refreshing destroys all progress.
Field progress tracking
Maintain a mental map of filled fields during each form. This prevents re-filling fields after re-snapshots and breaking out of loops.
FILLED = {
"first_name": {value: "Cheng-Wei", status: "ok"},
"last_name": {value: "Huang", status: "ok"},
"email": {value: "cwhuang.1004@gmail.com", status: "ok"},
"degree": {value: "Information Systems", status: "ok", attempts: 1},
...
}
Rules:
- Before filling any field — check if it's already in FILLED. If yes and status is "ok", skip it.
- After filling a field — add it to FILLED with value and status.
- After re-snapshot — only fill fields NOT in FILLED. Don't re-do completed fields.
- If a field in FILLED shows a wrong value in the snapshot — re-fill it, increment attempts.
- If attempts >= 3 for any field — stop trying that field, note it, move on.
- On page navigation (multi-step forms) — keep the map across steps.
- On new job — reset the map.
This prevents the loop where: fill field → re-snapshot → field looks empty in a11y tree → fill again → repeat forever.
Orchestrate parallel job applications: fetch JDs → tailor all → review → compile PDFs → fill forms → submit → track.
Prerequisites
agent-browserCLI installed and on PATH (agent-browser --versionto verify)~/work/personal/jobs/full-time/form_profile.mdmust exist with personal info~/work/personal/jobs/.envmust exist with JOBDITTO credentials- Claude Code (
claudeCLI) must be available - Auth vault configured for login-required portals (one-time setup — see below)
One-Time Setup: Auth Vault
For portals requiring persistent login (Glassdoor, LinkedIn), set up encrypted credentials:
echo "<password>" | agent-browser auth save glassdoor \
--url https://glassdoor.com/login \
--username cwhuang.1004@gmail.com --password-stdin
echo "<password>" | agent-browser auth save linkedin \
--url https://linkedin.com/login \
--username cwhuang.1004@gmail.com --password-stdin
Read credentials from form_profile.md — never hardcode passwords in this file.
Phase 1a: Fetch Job Descriptions (agent-browser)
Fetch all JDs before spawning tailor agents. This decouples fetching from tailoring.
SESSION="apply-batch-$(date +%Y-%m-%d-%H-%M)"
AB="agent-browser --session $SESSION --headless"
For each job URL:
$AB navigate "{url}"$AB wait --load networkidle$AB eval "document.querySelector('main, article, [role=\"main\"], .job-description, #job-details')?.innerText || document.body.innerText"- Save extracted text to
/tmp/apply-{job}/jd.txt - Prepend the original URL as the first line of jd.txt
If extraction returns <500 chars:
- Try iframe extraction:
$AB evalto iterate all iframes and extract text from the largest - Fall back to:
curl -sL '{url}' | sed 's/<[^>]*>//g' | head -500 - Last resort: WebFetch or ask user to paste
If page is behind auth/anti-bot:
- Try with
agent-browser --session $SESSION --cdp 9223(user's real Chrome) - If still blocked, ask user to paste the JD
Report: "Fetched {N}/{total} JDs" after each fetch.
Phase 1b: Parallel Tailoring (Claude Code)
Create git worktrees (one per job)
BASE=~/work/personal/jobs
COMMIT=$(git -C $BASE rev-parse main)
for job in job1 job2 job3; do
git -C $BASE worktree add --detach /tmp/apply-{job} $COMMIT
done
Copy jd.txt into each worktree:
cp /tmp/apply-{job}/jd.txt /tmp/apply-{job}/jd.txt
Spawn tailor agents in parallel (headless, no user interaction)
Token optimization: Tailor agents only need tailor-instructions.md + resume + JD. They do NOT need SKILL.md or platform guides. To prevent them from loading unnecessary context, create a minimal .claude/settings.local.json in each worktree that disables skill loading:
mkdir -p /tmp/apply-{job}/.claude
echo '{}' > /tmp/apply-{job}/.claude/settings.local.json
# Remove any CLAUDE.md that might pull in extra context
rm -f /tmp/apply-{job}/.claude/CLAUDE.md /tmp/apply-{job}/CLAUDE.md
For each job, run in background:
cd /tmp/apply-{job} && claude --permission-mode bypassPermissions --print \
"$(cat ~/.openclaw/workspace/.claude/skills/mass-apply/references/tailor-instructions.md)
The job description is in jd.txt (already fetched — do NOT use browser tools).
Read full-time/resume.typ and full-time/cover_letter.typ from this directory.
Write all proposed changes to .claude/draft.md.
DO NOT call AskUserQuestion or wait for user input.
Stop after draft.md is written." > /tmp/apply-{job}-tailor.log 2>&1 &
Spawn ALL simultaneously. Announce: "Tailoring {N} jobs in parallel..."
Monitor until all done
Poll every 30s: count /tmp/apply-*/. claude/draft.md files until count == N or processes exit. Timeout after 10 minutes per agent.
Phase 2: Validate Drafts (Six decides, no user input needed)
For each completed draft, read /tmp/apply-{job}/.claude/draft.md and apply these rules:
Auto-reject (skip silently, run cleanup):
- JD contains: "not eligible for sponsorship", "must be US Citizen", "Green Card only", "clearance required", "TS/SCI", "public trust", "permanent resident", "no visa sponsorship", "unable to sponsor"
- Mark as rejected in jobs.db:
python3 ~/work/personal/jobs/.claude/scripts/jobs_db.py mark --url "{url}" --status rejected - Notify user: "Skipped {Company} — sponsorship blocker"
Auto-approve (proceed without asking user):
- No sponsorship blocker
- Skills changes are reorders only (removed count = 0 or 1 minor skill)
- Cover letter bullets selected from existing bank (no new bullets invented)
- Experience changes are wording/keyword adjustments only
Flag to user (ask before proceeding):
- Skills removed count > 2 (may hurt ATS)
- New experience bullet points were fabricated
- Cover letter intro was rewritten beyond company/role variable swaps
- Unusual changes that seem off
Announce summary: "Approved: {Company} — {brief changes}" for each auto-approved job.
Phase 3: Compile PDFs (Six directly)
For each approved worktree, Six applies draft edits and compiles PDFs:
- Read
/tmp/apply-{job}/.claude/draft.mdfor all OLD → NEW changes - Apply each edit to
full-time/resume.typandfull-time/cover_letter.typin the worktree - Compile PDFs:
typst compile /tmp/apply-{job}/full-time/resume.typ /tmp/openclaw/uploads/resume_{Company}_{Role}_{date}.pdf typst compile /tmp/apply-{job}/full-time/cover_letter.typ /tmp/openclaw/uploads/cover_letter_{Company}_{Role}_{date}.pdf - Verify both PDFs exist and are non-zero
Phase 4: Fill Application Forms (agent-browser)
Session initialization
AB_HEADLESS="agent-browser --session ${SESSION}-headless --headless"
AB_CONNECT="agent-browser --session ${SESSION}-connect --cdp 9223"
Chrome locale override
Simon's Chrome profile is zh-TW, which causes geocode APIs to return Chinese results (聖荷西 instead of San Jose). Override the locale on every session:
# For auto-connect sessions (real Chrome):
$AB_CONNECT eval "Object.defineProperty(navigator, 'language', {get: () => 'en-US', configurable: true})"
$AB_CONNECT eval "Object.defineProperty(navigator, 'languages', {get: () => ['en-US', 'en'], configurable: true})"
This must run before navigating to any job page. The Chinese text matching rules (聖荷西, 加利福尼亞, etc.) remain as fallback for cached results.
Sort jobs: all headless-compatible first, then --cdp 9223 (real Chrome).
Platform detection and mode selection
From the job URL:
| Platform | URL Pattern | Mode | Quirk Guide |
|---|---|---|---|
| Workday | *.wd{1-5}.myworkdayjobs.com |
$AB_HEADLESS |
platforms/workday.md |
| Greenhouse / Ashby | boards.greenhouse.io, job-boards.greenhouse.io, ashbyhq.com |
$AB_HEADLESS |
platforms/greenhouse.md |
| Lever | jobs.lever.co |
$AB_HEADLESS |
platforms/lever.md |
| Dayforce | *.dayforcehcm.com, *.dayforce.com |
$AB_HEADLESS |
platforms/dayforce.md |
| SmartRecruiters | jobs.smartrecruiters.com, careers.smartrecruiters.com |
$AB_HEADLESS |
platforms/smartrecruiters.md |
| LinkedIn Easy Apply | linkedin.com/jobs (Easy Apply modal) |
$AB_CONNECT |
platforms/linkedin-easy-apply.md |
| LinkedIn → external | linkedin.com/jobs (redirects to ATS) |
$AB_CONNECT → detect ATS |
Use ATS platform guide |
| Glassdoor Easy Apply | glassdoor.com (Easy Apply form) |
$AB_CONNECT |
platforms/glassdoor.md |
| Glassdoor → external | glassdoor.com (Apply on Company Site) |
$AB_CONNECT → detect ATS |
Use ATS platform guide |
| Indeed | indeed.com (redirects to ATS) |
$AB_CONNECT |
— |
| Oracle HCM | *.fa.*.oraclecloud.com/hcmUI/CandidateExperience |
$AB_CONNECT |
platforms/oracle-hcm.md |
| Gem.com | jobs.gem.com |
$AB_HEADLESS (partial) |
See Platform Limitations — file upload + CAPTCHA require user |
| Unknown | — | $AB_CONNECT |
— |
Set $AB to the appropriate prefix for the current job's platform.
Always read the platform quirk guide before starting the form (if one exists).
URL resolution priority
A job may have multiple DB rows (e.g., one from LinkedIn, one from Simplify with a direct ATS URL). Always prefer direct ATS URLs over aggregator links:
- Before navigating, query DB for ALL rows matching the same company+role. If a direct ATS URL exists (Workday, Greenhouse, Ashby, Lever, etc.), use it instead of the LinkedIn/Indeed URL.
- Direct ATS URLs (use immediately — no redirect needed):
*.wd{1-5}.myworkdayjobs.com→ Workdayboards.greenhouse.io,job-boards.greenhouse.io→ Greenhousejobs.ashbyhq.com→ Ashbyjobs.lever.co→ Lever
- Aggregator URLs (LinkedIn, Indeed, Glassdoor) → must resolve first (see below)
Handling 404 / expired pages
After navigating to any URL and taking a snapshot:
- Check for 404 indicators: page title contains "not found", "no longer available", "expired", "removed", or the page body is mostly empty / shows an error.
- If 404/expired:
- Close the tab immediately
- Mark the job as
closedin jobs.db:python3 ~/work/personal/jobs/.claude/scripts/jobs_db.py mark --url "{url}" --status closed - Log: "Closed: {Company} — posting expired"
- Move to next job. Do NOT retry the same URL.
Resolving non-ATS URLs
Jobs from /find-jobs may have LinkedIn, Glassdoor, or Indeed URLs instead of direct ATS links:
- Navigate to the job URL:
$AB navigate "{url}"→$AB wait --load networkidle $AB snapshot -i— check for 404 first (see above), then look for "Apply" / "Easy Apply" / "Apply on Company Site"- If "Easy Apply" (LinkedIn or Glassdoor) → stay on platform, use the Easy Apply guide
- If "Apply" / "Apply on Company Site" → click it:
Check final URL → re-detect ATS platform (Workday/Greenhouse/Lever) → use that guide$AB click @apply-button $AB wait --load networkidle - If new tab opened →
$AB tab→ switch to new tab → get URL → detect platform
NEVER kill Chrome
Do NOT run agent-browser close, agent-browser quit, or any command that shuts down the browser process. Simon's Chrome session has logged-in state across many sites. Killing it destroys all tab state and forces re-login everywhere. Only close individual tabs after successful submission.
LinkedIn Easy Apply daily cap
Track Easy Apply count in jobs.db. Cap at 20 per day. Check before each LinkedIn Easy Apply:
python3 ~/work/personal/jobs/.claude/scripts/jobs_db.py list --source linkedin --status applied --days 1
If count >= 20, skip LinkedIn Easy Apply jobs for this run.
Auth (auto-connect platforms only)
For platforms with persistent accounts (Glassdoor, LinkedIn):
$AB auth login glassdoor
$AB wait --text "Sign Out"
Tab management — ONE tab per job
- Open one new tab per job. Never open a second tab for the same job.
- Track a mapping:
{job_id: tab_id}throughout the batch. - If clicking "Apply" opens a new tab → switch to the new tab, continue there. Don't go back to the old tab.
- If an error occurs → re-snapshot the current tab. Do NOT open a new tab as retry.
- After successful submit → track on jobditto, close the tab.
- If submit fails (CAPTCHA) → leave the tab open for Simon to manually submit later.
Submit flow (per job)
After all fields are filled:
-
Try submit:
$AB click @submit-button $AB wait --load networkidle --timeout 10000 $AB snapshot -i -
Check result (5s after click):
- Success — page shows confirmation text ("submitted", "thank you", "received", "confirmation") → track on jobditto, close tab, move to next job ✅
- Visible CAPTCHA appeared (checkbox, image puzzle,
.g-recaptcha,.h-captchavisible) → go to CapSolver flow below - Page didn't advance (still on form, no error, no confirmation) → retry submit once more (attempt 2)
- Error message (validation error, missing field) → read error, fix the field, retry submit
-
After 2 failed submit attempts → leave tab open, add to manual submit list. Move to next job immediately.
CapSolver — visible CAPTCHAs only
If a visible CAPTCHA widget appears (checkbox you need to click, image puzzle to solve):
CAPTCHA_INFO=$($AB eval "
const rc = document.querySelector('.g-recaptcha');
const hc = document.querySelector('.h-captcha');
if (rc) JSON.stringify({type: 'recaptchav2', sitekey: rc.dataset.sitekey});
else if (hc) JSON.stringify({type: 'hcaptcha', sitekey: hc.dataset.sitekey});
else {
const iframe = document.querySelector('iframe[src*=recaptcha]');
if (iframe) JSON.stringify({type: 'recaptchav2', sitekey: iframe.src.match(/k=([^&]+)/)?.[1]});
else 'null';
}
")
TOKEN=$(python3 ~/work/personal/jobs/.claude/scripts/solve_captcha.py \
--type "$CAPTCHA_TYPE" --url "{current_page_url}" --sitekey "$SITEKEY" --timeout 120)
# Inject token
$AB eval "document.getElementById('g-recaptcha-response').value = '{TOKEN}'"
$AB eval "const cb = document.querySelector('.g-recaptcha')?.dataset?.callback; if(cb && window[cb]) window[cb]('{TOKEN}')"
# For hCaptcha:
$AB eval "const ta = document.querySelector('textarea[name=\"h-captcha-response\"]'); if(ta) ta.value = '{TOKEN}'"
# Retry submit after solving
$AB click @submit-button
$AB wait --load networkidle
If CapSolver fails → try ydotool real click below, then manual submit list.
Do NOT use CapSolver for invisible CAPTCHAs — token injection doesn't work on invisible reCAPTCHA Enterprise (Greenhouse new UI, Ashby).
ydotool real OS click (attempt 2 — for invisible CAPTCHAs)
If the first submit attempt failed and no visible CAPTCHA appeared (invisible CAPTCHA likely blocking), try a real OS-level click via ydotool. This produces genuine isTrusted: true events that pass invisible CAPTCHA scoring.
Only works when Chrome is visible and focused on screen. Won't work if screen is locked or Chrome is minimized.
# Get submit button viewport coordinates + Chrome UI offset
COORDS=$($AB eval "
const btn = document.querySelector('button[type=submit], input[type=submit], [data-testid=submit]');
if (btn) {
btn.scrollIntoView({block: 'center'});
const r = btn.getBoundingClientRect();
const chromeOffset = window.outerHeight - window.innerHeight;
JSON.stringify({x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2), chromeOffset});
} else 'null';
")
# Real OS click with humanized mouse movement
python3 ~/work/personal/jobs/.claude/scripts/real_click.py \
--viewport --x {x} --y {y} --chrome-offset {chromeOffset}
Wait 5s, check if page advanced. If still blocked → leave tab open, add to manual submit list.
Batch completion — notify Simon
After all jobs in the batch are processed, send ONE Discord message:
Batch complete. {N} submitted ✅, {M} need your click:
• Attentive — SWE I, AI Product ($140-170k) — job-boards.greenhouse.io/attentive/...
• Benchling — SWE, Developer Platform ($136-167k) — jobs.ashbyhq.com/benchling/...
• AppLovin — Backend New Grad ($124-186k) — boards.greenhouse.io/applovin/...
Open Chrome tabs and click Submit on each. Reply 'done' when finished.
When Simon replies 'done':
- Check each tab — look for confirmation text
- Track successful ones on jobditto
- Close all tabs
- Send final report: "All done. {total} applied today."
If Simon doesn't reply within 2 hours: Don't resend. The tabs stay open. He'll get to them.
Known CAPTCHA behaviors by platform
| Platform | CAPTCHA Type | Tier | Notes |
|---|---|---|---|
| Greenhouse (new, job-boards.) | reCAPTCHA Enterprise invisible | Tier 1 (CDP click) | Auto-passes with real Chrome |
| Greenhouse (old, boards.) | reCAPTCHA v2 standard | Tier 2 (CapSolver) | Email OTP also needed |
| Ashby | reCAPTCHA v2 invisible | Tier 1 (CDP click) | React SPA rejects injected tokens |
| iCIMS | hCaptcha | Tier 1 (CDP click) | hCaptcha hangs with synthetic events |
| Workday | Bot fingerprinting | Tier 1 (use $AB_CONNECT) | No CAPTCHA UI, just behavioral scoring |
| Lever | None | — | Clean submission |
| Tarro | reCAPTCHA v2 standard | Tier 2 (CapSolver) | Visible checkbox |
Account creation (Workday, some Greenhouse)
Detection: if snapshot shows "Create Account" / "Sign Up" → account creation flow.
-
Try login first:
$AB fill @email "cwhuang.1004@gmail.com" $AB fill @password "<from form_profile.md>" $AB click @sign-in $AB wait --load networkidle -
If login fails → create account:
$AB fill @email "cwhuang.1004@gmail.com" $AB fill @password "<Job Portal Password from form_profile.md>" $AB fill @verify-password "<same>" $AB click @create-account $AB wait --load networkidle -
Email verification via Gmail MCP:
- Wait ~10s for delivery
gmail_search_messages: query"from:workday verify"or"from:{company} verify"gmail_read_message: extract verification link$AB navigate "{verification_link}"- Navigate back to job application URL
Credentials from form_profile.md:
- Email:
cwhuang.1004@gmail.com - Default password: from "Job Portal Password" field
- CVS Health: from "CVS Health Password" field (portal-specific override)
Snapshot budget (token optimization)
Snapshots are the #1 token cost — each full a11y tree is 2-5k tokens. Budget: max 5 snapshots per form page.
When to snapshot:
- Once on initial page load
- Once after page navigation (Next/Continue/Save)
- Once before submit (for the pre-submit review)
- On error (field not found, validation error)
When NOT to snapshot:
- After filling a single field — use
evalto verify instead - After clicking a dropdown option — trust the click, move on
- After file upload — verify with eval:
$AB eval "document.querySelector('#resume')?.files?.length" - To "check" if things look right — trust the FILLED map
Verify fields via eval instead of re-snapshotting:
# Check a field value (1 line of output vs 200-line snapshot)
$AB eval "document.querySelector('[name=firstName]')?.value"
# Check if dropdown has selection
$AB eval "document.querySelector('[id*=degree] .css-1uccc91-singleValue')?.textContent"
# Check how many fields are still empty
$AB eval "Array.from(document.querySelectorAll('input[required]')).filter(i=>!i.value).map(i=>i.name||i.id).join(', ')"
If you hit 5 snapshots on one page and still have unfilled fields, use eval-based filling:
$AB eval "document.querySelector('input[name=field]').value = 'value'; document.querySelector('input[name=field]').dispatchEvent(new Event('input', {bubbles:true}))"
Simplify fast path (try first, fall back to manual fill)
Simplify Copilot (Chrome extension) can autofill static fields (name, email, EEO, education, work history, etc.) natively — handling platform quirks like React Select, Workday dates, and intl-tel-input automatically. Six only handles the tailored parts.
Simplify injects DOM elements into the page. Look for button "Autofill" in the snapshot. It's a regular DOM element Six can click via CDP.
1. $AB navigate "{job_url}"
2. $AB wait --load networkidle
3. $AB snapshot -i ← snapshot #1
4. Check: is this a JD page or a form page?
JD page indicators: "Apply Now", "Apply", "Submit Application" button but NO form fields
(no text inputs, no dropdowns, no file uploads — just job description content)
Form page indicators: text inputs, dropdowns, file upload fields visible
IF JD page → click the Apply/Apply Now button first:
$AB click @apply-button
$AB wait --load networkidle
$AB snapshot -i ← snapshot #2 (now on form)
(If new tab opened → switch to it)
5. Now on form page. Check: is there a button with text "Autofill" (Simplify)?
YES → Simplify fast path:
a. $AB click @autofill-button ← click Simplify's Autofill
b. sleep 3 ← wait for autofill to complete
c. $AB eval "document.querySelector('input[name*=first], input[name*=First], input[id*=first]')?.value"
→ If non-empty: Simplify worked. Skip to "Tailored parts only" below.
→ If empty: Simplify failed. Fall through to dynamic fill loop.
NO → Fall through to dynamic fill loop.
Tailored parts only (after Simplify autofill):
5. $AB snapshot -i ← snapshot (see what's left)
6. Upload tailored resume if file upload field exists on this page:
$AB upload "#resume" /tmp/openclaw/uploads/resume_{Company}_{Role}_{date}.pdf
(Try common selectors: "#resume", "input[type=file]", @file-input-ref)
7. Upload tailored cover letter if field exists on this page:
$AB upload "#cover_letter" /tmp/openclaw/uploads/cover_letter_{Company}_{Role}_{date}.pdf
8. Fill any free-text custom questions (job-specific):
- Read jd.txt + resume.typ + cover_letter.typ
- Draft 2-4 sentence answer grounded in real experience
- $AB fill @question-ref "answer"
9. Verify required fields via eval:
$AB eval "Array.from(document.querySelectorAll('input[required],select[required],textarea[required]')).filter(i=>!i.value).map(i=>i.name||i.id||i.placeholder).join(', ')"
→ If any empty required fields: fill them manually via CDP
10. If Next/Continue → click, wait, then RE-RUN Simplify on the new page:
$AB wait --load networkidle
$AB snapshot -i
→ If Autofill button visible: $AB click @autofill-button → sleep 3
→ Go back to step 5
11. If Submit/Review → pre-submit pause
This path uses 2-3 snapshots total instead of 30-50. If Simplify isn't installed or fails, the dynamic fill loop below handles everything.
Dynamic fill loop (fallback — used when Simplify is not available)
For each job:
1. $AB navigate "{job_url}"
2. $AB wait --load networkidle
3. $AB snapshot -i ← snapshot #1
Fill phase (NO snapshots — use refs from snapshot #1):
4. Classify each field in snapshot by matching label to form_profile.md
5. Batch-fill simple fields:
$AB batch --json '[{"cmd":"fill","ref":"@first-name","value":"Cheng-Wei"}, ...]'
6. Handle special fields per platform quirk guide overrides
7. Handle free-text questions:
- Read resume.typ + cover_letter.typ + jd.txt
- Find most relevant experience bullets
- Draft 2-4 sentence answer grounded in real experience
- Fill the field
- Log Q&A to /tmp/apply-{job}/form-qa.md
8. Handle file uploads:
$AB upload @file-input-ref /tmp/openclaw/uploads/resume_{Company}_{Role}_{date}.pdf
8b. Handle cover letter fields:
- If file upload → $AB upload @ref /path (PDF includes full letterhead)
- If text area → paste body only (greeting + intro + bullets + closing).
Skip the header (address, phone, date, recipient) — already in form fields.
Verify phase (eval, not snapshot):
9. $AB eval "Array.from(document.querySelectorAll('input[required],select[required],textarea[required]')).filter(i=>!i.value).map(i=>i.name||i.id||i.placeholder).join(', ')"
If empty → all required fields filled, proceed
If not empty → fill missing fields by name/id, re-verify with eval
Navigate:
10. If Next/Continue button → click, wait networkidle, snapshot ← snapshot #2+
11. If Submit/Review → exit loop → pre-submit pause
12. If neither and fields remain → ONE re-snapshot max, then eval-only
Core patterns
Typeahead/autocomplete (replaces hardcoded "WAIT 2 seconds"):
$AB type @ref "search term"
$AB wait --load networkidle
$AB snapshot -i
$AB click @matching-option
Fallback: $AB wait --fn "() => document.querySelectorAll('[role=option]').length > 0"
intl-tel-input phone picker conflict (Greenhouse, Ashby — blocks ALL dropdowns and keyboard events):
If the form has a phone field with country picker (.iti class), disable it FIRST before filling any dropdowns:
# DISABLE picker — run this before filling any React Select dropdowns
$AB eval "
const fc = document.querySelector('.iti__flag-container');
if (fc) fc.style.setProperty('pointer-events', 'none');
const cl = document.querySelector('#iti-0__country-listbox');
if (cl) cl.style.display = 'none';
"
Now fill dropdowns normally (focus + ArrowDown or click):
$AB eval "document.querySelector('[id*=FIELD_NAME] input, [class*=select] input')?.focus()"
$AB press ArrowDown
$AB wait --load networkidle
$AB snapshot -i
$AB find role option click --name "desired value"
RE-ENABLE picker before filling phone field:
$AB eval "
const fc = document.querySelector('.iti__flag-container');
if (fc) fc.style.removeProperty('pointer-events');
const cl = document.querySelector('#iti-0__country-listbox');
if (cl) cl.style.display = '';
"
Phone country code (after re-enabling picker):
$AB eval "document.querySelector('.iti__selected-flag')?.click()"
$AB wait --load networkidle
$AB eval "document.querySelector('[data-country-code=\"us\"]')?.click()"
Always set to United States (+1) before filling the phone number.
Address / location dropdowns (city, state, country may be typeahead or React Select):
# Type the city name and wait for dropdown options
$AB type @city-input "San Jose"
$AB wait --load networkidle
$AB snapshot -i
# Pick the California option — may appear as:
# "San Jose, CA", "San Jose, California", "聖荷西, 加利福尼亞", etc.
# ALWAYS pick the one that includes California/CA — not San Jose in other states
$AB find role option click --name "San Jose" # or click the ref that includes CA
If the dropdown shows results in Chinese or another language, match by:
- State: California = CA = 加利福尼亞 = 加州
- City: San Jose = 聖荷西
- Country: United States = US = USA = 美國 If unsure which option is correct, pick the one containing "95131" (zip) or "CA"/"California".
Email OTP verification (may appear during login or submission on any platform): If after clicking Submit/Apply the page shows "Enter verification code", "Check your email", or "Security code":
- Wait 15s for email delivery
- Search Gmail:
gmail_search_messages: "security code OR verification code OR verify your from:(greenhouse OR indeed OR glassdoor OR akamai OR workday OR noreply)" (last 10 min) - Read the message —
gmail_read_messagewith the first result's ID - Extract the code — look for 6-8 character alphanumeric codes (NOT just 6-digit numeric):
- Greenhouse: 8-char alphanumeric (e.g.,
NPHVBc6T), subject: "Security code for your application to {company}" - Indeed/Glassdoor: 6-digit numeric
- Akamai: varies
- Greenhouse: 8-char alphanumeric (e.g.,
- Fill and submit:
$AB fill @verification-code-input "{code}" $AB click @verify-button $AB wait --load networkidle - If not found after 15s: search again (email may be delayed)
- If not found after 60s: Discord message: "Check spam for verification code from {platform}"
- If multiple codes found: use the most recent one (first result)
Shadow DOM / custom web components (SmartRecruiters, some modern ATS):
If snapshot -i shows no form fields but you can see a form visually, the platform likely uses Shadow DOM.
# Detect shadow roots
$AB eval "Array.from(document.querySelectorAll('*')).filter(el => el.shadowRoot).map(el => el.tagName.toLowerCase())"
# Access fields inside shadow root
$AB eval "document.querySelector('custom-element')?.shadowRoot?.querySelector('input[name=\"field\"]').value = 'value'"
$AB eval "document.querySelector('custom-element')?.shadowRoot?.querySelector('input[name=\"field\"]').dispatchEvent(new Event('input', {bubbles: true, composed: true}))"
Events must have composed: true to bubble out of shadow DOM. See platforms/smartrecruiters.md for full guide.
Batch fill (for static field groups):
$AB batch --json '[{"cmd":"fill","ref":"@name","value":"Cheng-Wei"}, {"cmd":"fill","ref":"@email","value":"cwhuang.1004@gmail.com"}]'
File upload (single command, no arm-then-click):
$AB upload @file-input-ref /tmp/openclaw/uploads/resume.pdf
Page navigation (every page transition):
$AB click @next-button
$AB wait --load networkidle
$AB snapshot -i
Personal data reference (from form_profile.md)
| Field | Value |
|---|---|
| Legal First Name | Cheng-Wei |
| Legal Last Name | Huang |
| Full Name | Cheng-Wei Huang |
| Preferred Name | Simon |
| cwhuang.1004@gmail.com | |
| Phone | 6692934702 |
| Phone (formatted) | (669) 293-4702 |
| Address | 1447 Whitewood Ct, San Jose, CA 95131 |
| https://www.linkedin.com/in/simon198 | |
| GitHub | https://github.com/SimonOneNineEight |
| Work Auth | F-1 STEM OPT |
| Needs Sponsorship | Yes |
| US Citizen | No |
| Hispanic/Latino | No |
| Start Date | May 1, 2026 |
| Salary | Negotiable |
| Gender | Male |
| Race/Ethnicity | Asian |
| Veteran Status | Not a veteran |
| Disability | No disability |
| How did you hear | Online job board |
| Highest Degree | Master's Degree |
| Previous Employee | No |
For full work experience, education, and languages → read form_profile.md.
For portal passwords → read .env.
Pre-submit pause
ALWAYS pause before submitting:
$AB screenshot- Show summary of all filled fields
- Wait for explicit user confirmation ("submit" / "yes")
- Only then:
$AB click @submit-button
Phase 5: Track + Cleanup
Per submitted job:
-
Capture confirmation screenshot:
$AB screenshot --fullSave to
/tmp/apply-{job}/confirmation.png -
Track on jobditto.com (with notes):
python3 ~/work/personal/jobs/.claude/scripts/track_application.py \ --base-path ~/work/personal/jobs \ --notes-file /tmp/apply-{job}/form-qa.md -
Revert .typ files:
git -C ~/work/personal/jobs checkout -- full-time/resume.typ full-time/cover_letter.typ -
Delete compiled PDFs (ls first to get exact names, then rm each):
ls /tmp/openclaw/uploads/resume_{Company}_*.pdf /tmp/openclaw/uploads/cover_letter_{Company}_*.pdf rm /tmp/openclaw/uploads/<exact_resume_filename> rm /tmp/openclaw/uploads/<exact_cover_letter_filename> -
Remove worktree:
git -C ~/work/personal/jobs worktree remove /tmp/apply-{job} --force
For skipped/failed jobs:
Remove worktree directly (no tracking needed).
Batch summary (after all jobs):
BATCH COMPLETE — {date}
Submitted: Company A (Workday), Company B (Greenhouse)
Skipped: Company C (sponsorship blocker)
Failed: Company D (CAPTCHA not solved)
Tracked: 2/4 on jobditto.com
Multi-Job Sequencing
Initialize: SESSION="apply-batch-$(date +%Y-%m-%d-%H-%M)"
Phase 1a: Fetch all JDs via agent-browser (sequential)
Phase 1b: Spawn all tailor agents (parallel)
Phase 2: Validate all drafts
Phase 3: Compile all PDFs
Phase 4 [headless batch]:
AB=$AB_HEADLESS
Job 1: Fill → Submit → Track
Job 2: Fill → Submit → Track
Phase 4 [--cdp 9223 batch]:
AB=$AB_CONNECT
Job 3: Auth → Fill → Submit → Track
Job 4: Fill → Submit → Track (auth persists in session)
Phase 5: Batch summary
Platform Limitations
Gem.com (jobs.gem.com)
- File inputs have no id, no name, no aria-label — cannot be targeted by stable selectors
agent-browser uploadfails because it needs a unique selector- Form also has reCAPTCHA which blocks headless submission
- Handling: Fill all text fields with agent-browser, then hand off to user for:
- Manual file upload (drag-drop PDFs into the form)
- Solving reCAPTCHA
- Clicking submit
- Workaround to document: Use
agent-browser --headedto open visible browser (requires stopping daemon first:agent-browser close && AGENT_BROWSER_HEADED=true agent-browser open ...)
Error Handling
Notification: All manual-intervention errors → Discord message to Simon. Six waits for resolution before continuing.
Phase 1a — JD fetching
- Page behind auth → fall back: curl → WebFetch → ask user to paste
- Anti-bot blocks headless → try
$AB_CONNECT→ if still blocked, ask user to paste - JS SPA not loaded →
$AB wait --fn "() => document.body.innerText.length > 500" --timeout 15000 - Iframe-embedded JD →
$AB evalto iterate frames, extract from largest - Redirect to different company → warn user, ask whether to proceed
Phase 1b — Tailoring
- Claude Code crashes → check log, notify user, offer retry or skip
- draft.md missing after 10min → kill process, notify, skip job
- draft.md bad format → re-run tailor once, then skip
Phase 3 — PDF compilation
- typst error → read error, fix .typ syntax, retry
- 0-byte PDF → recompile, check .typ if still fails
Phase 4 — Form filling
Browser-level:
- Session/daemon dies → new session
${SESSION}-retry, re-navigate - Page timeout → retry navigation once, then skip job + notify
Account creation:
- Login fails + "already exists" on create → Discord: ask user to reset password
- Password requirements not met → try appending characters per error message
- Verification email missing → wait 30s, re-search Gmail, then 60s → Discord: "check spam"
- Verification link expired → click "Resend", re-search Gmail
Form filling:
- Element not found →
wait --load networkidle→ re-snapshot → retry once - Field fill doesn't stick (React) → try
$AB typekeystroke simulation instead of$AB fill - Workday date spinners reset → expected; re-fill after every page navigation
- Typeahead no results → skip that skill, log for user
- Two-tier dropdown second tier won't open → re-click first tier, wait, retry
- "Errors Found" (Workday validation) → re-snapshot, find error fields, fix, re-click Next
- File upload fails → try clicking upload zone first, then
$AB upload - Unexpected modal/dialog → snapshot to identify, dismiss if cookie banner, re-login if session expired
- Dynamic form expansion → normal; re-snapshot and continue fill loop
CAPTCHA:
- Detected → Discord: "CAPTCHA on {platform}, solve in browser" →
$AB wait --fn "() => !document.querySelector('[class*=captcha], iframe[src*=captcha], .g-recaptcha')" --timeout 120000→ re-snapshot → continue - Appears repeatedly → add 2-3s delays between actions; if persists, switch to
--cdp 9223
Session timeout:
- Detect from snapshot (login page instead of form) → re-login → navigate back → Workday "Save and Continue" preserves partial progress
- If no "Save and Continue" → re-fill from scratch
Partial fill recovery:
- If Six crashes mid-form → navigate back to job URL → if "Continue Application" exists, resume → otherwise re-fill from scratch
Tracking fails
- Script error → show output, offer retry or skip tracking
- Cleanup still runs regardless
Platform Guide Contract
Platform quirk guides are in platforms/. They document only what deviates from the dynamic fill default.
What guides can assume:
$ABis set with session + mode flags- Page is loaded and
wait --load networkidlecompleted - Initial
snapshot -iis taken /tmp/openclaw/uploads/has compiled PDFs- form_profile.md data is available (inline table above)
What guides must do:
- Use
$ABfor all commands - After every navigation:
$AB wait --load networkidlethen$AB snapshot -i - For typeahead: use
wait --load networkidleorwait --fn, never hardcoded delays - For uploads:
$AB upload @ref /path(single command) - Do NOT click submit — SKILL.md handles the safety gate
Status Updates
Keep user informed throughout:
- "Fetching JD: {Company} ({N}/{total})..."
- "Tailoring {N} jobs in parallel..."
- "Draft ready: {Company} — {changes summary}"
- "Compiling PDFs: {Company}..."
- "Filling form: {Company} ({platform})..."
- "Submitted: {Company}"
- "Tracked: {Company} → jobditto.com"
- Final batch summary table