Apply to multiple jobs in parallel using agent-browser for form automation. Fetches JDs, spawns Claude Code agents to tailor resume/cover letter for each job simultaneously, fills and submits each application form, then tracks each submission on jobditto.com. Use when the user provides multiple job URLs and wants to apply to all of them. Triggers on: 'apply to these jobs', 'mass apply', 'apply to all', multiple job URLs in one message.
Read this file and all platform guides in
platforms/fresh every time. Do not rely on memory or previous runs.
Most ATS forms wipe ALL fields on refresh. If you hit an error mid-fill, follow this decision tree:
Recoverable (re-snapshot, keep going):
eval focus() + ArrowDowneval with value setter + dispatchEventBroken (stop filling this job, move to next):
skipped.skippedskippedskippedfilled (partially done).NEVER: Refresh, reload, or navigate away from a half-filled form. Refreshing destroys all progress.
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: "[email protected]", status: "ok"},
"degree": {value: "Information Systems", status: "ok", attempts: 1},
...
}
Rules:
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.
agent-browser CLI installed and on PATH (agent-browser --version to verify)~/work/personal/jobs/full-time/form_profile.md must exist with personal info~/work/personal/jobs/.env must exist with JOBDITTO credentialsclaude CLI) must be availableFor portals requiring persistent login (Glassdoor, LinkedIn), set up encrypted credentials:
echo "<password>" | agent-browser auth save glassdoor \
--url https://glassdoor.com/login \
--username [email protected] --password-stdin
echo "<password>" | agent-browser auth save linkedin \
--url https://linkedin.com/login \
--username [email protected] --password-stdin
Read credentials from form_profile.md — never hardcode passwords in this file.
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"/tmp/apply-{job}/jd.txtIf extraction returns <500 chars:
$AB eval to iterate all iframes and extract text from the largestcurl -sL '{url}' | sed 's/<[^>]*>//g' | head -500If page is behind auth/anti-bot:
agent-browser --session $SESSION --cdp 9223 (user's real Chrome)Report: "Fetched {N}/{total} JDs" after each fetch.
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
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..."
Poll every 30s: count /tmp/apply-*/. claude/draft.md files until count == N or processes exit. Timeout after 10 minutes per agent.
For each completed draft, read /tmp/apply-{job}/.claude/draft.md and apply these rules:
python3 ~/work/personal/jobs/.claude/scripts/jobs_db.py mark --url "{url}" --status rejected
Announce summary: "Approved: {Company} — {brief changes}" for each auto-approved job.
For each approved worktree, Six applies draft edits and compiles PDFs:
/tmp/apply-{job}/.claude/draft.md for all OLD → NEW changesfull-time/resume.typ and full-time/cover_letter.typ in the worktreetypst 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
AB_HEADLESS="agent-browser --session ${SESSION}-headless --headless"
AB_CONNECT="agent-browser --session ${SESSION}-connect --cdp 9223"
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).
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).
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:
*.wd{1-5}.myworkdayjobs.com → Workdayboards.greenhouse.io, job-boards.greenhouse.io → Greenhousejobs.ashbyhq.com → Ashbyjobs.lever.co → LeverAfter navigating to any URL and taking a snapshot:
closed in jobs.db: python3 ~/work/personal/jobs/.claude/scripts/jobs_db.py mark --url "{url}" --status closedJobs from /find-jobs may have LinkedIn, Glassdoor, or Indeed URLs instead of direct ATS links:
$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"$AB click @apply-button
$AB wait --load networkidle
Check final URL → re-detect ATS platform (Workday/Greenhouse/Lever) → use that guide$AB tab → switch to new tab → get URL → detect platformDo 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.
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.
For platforms with persistent accounts (Glassdoor, LinkedIn):
$AB auth login glassdoor
$AB wait --text "Sign Out"
{job_id: tab_id} throughout the batch.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):
.g-recaptcha, .h-captcha visible) → go to CapSolver flow belowAfter 2 failed submit attempts → leave tab open, add to manual submit list. Move to next job immediately.
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).
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.
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':
If Simon doesn't reply within 2 hours: Don't resend. The tabs stay open. He'll get to them.
| 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 |
Detection: if snapshot shows "Create Account" / "Sign Up" → account creation flow.
Try login first:
$AB fill @email "[email protected]"
$AB fill @password "<from form_profile.md>"
$AB click @sign-in
$AB wait --load networkidle
If login fails → create account:
$AB fill @email "[email protected]"
$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:
gmail_search_messages: query "from:workday verify" or "from:{company} verify"gmail_read_message: extract verification link$AB navigate "{verification_link}"Credentials from form_profile.md:
[email protected]Snapshots are the #1 token cost — each full a11y tree is 2-5k tokens. Budget: max 5 snapshots per form page.
When to snapshot:
When NOT to snapshot:
eval to verify instead$AB eval "document.querySelector('#resume')?.files?.length"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 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.
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
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:
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":
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)
gmail_read_message with the first result's IDNPHVBc6T), subject: "Security code for your application to {company}"$AB fill @verification-code-input "{code}"
$AB click @verify-button
$AB wait --load networkidle
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":"[email protected]"}]'
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
| Field | Value |
|---|---|
| Legal First Name | Cheng-Wei |
| Legal Last Name | Huang |
| Full Name | Cheng-Wei Huang |
| Preferred Name | Simon |
| [email protected] | |
| 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.
ALWAYS pause before submitting:
$AB screenshot$AB click @submit-buttonCapture confirmation screenshot:
$AB screenshot --full
Save 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
Remove worktree directly (no tracking needed).
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
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
agent-browser upload fails because it needs a unique selectoragent-browser --headed to open visible browser (requires stopping daemon first: agent-browser close && AGENT_BROWSER_HEADED=true agent-browser open ...)Notification: All manual-intervention errors → Discord message to Simon. Six waits for resolution before continuing.
$AB_CONNECT → if still blocked, ask user to paste$AB wait --fn "() => document.body.innerText.length > 500" --timeout 15000$AB eval to iterate frames, extract from largestBrowser-level:
${SESSION}-retry, re-navigateAccount creation:
Form filling:
wait --load networkidle → re-snapshot → retry once$AB type keystroke simulation instead of $AB fill$AB uploadCAPTCHA:
$AB wait --fn "() => !document.querySelector('[class*=captcha], iframe[src*=captcha], .g-recaptcha')" --timeout 120000 → re-snapshot → continue--cdp 9223Session timeout:
Partial fill recovery:
Platform quirk guides are in platforms/. They document only what deviates from the dynamic fill default.
What guides can assume:
$AB is set with session + mode flagswait --load networkidle completedsnapshot -i is taken/tmp/openclaw/uploads/ has compiled PDFsWhat guides must do:
$AB for all commands$AB wait --load networkidle then $AB snapshot -iwait --load networkidle or wait --fn, never hardcoded delays$AB upload @ref /path (single command)Keep user informed throughout: