## Geometra Form-Fill Patterns

### Validation State Lags Behind Actual Field State

**This is a known issue across Greenhouse, Ashby, and similar ATS portals.** The frontend validation does not always update synchronously with field input. A field can be correctly filled but still show `invalid: true` or "This field is required" in the schema for 3-10 seconds — or even permanently until the user interacts with another field.

**Common false-positive patterns:**
- `set_checked` / `geometra_set_checked` sets a checkbox to `checked: true`, but the schema still shows `invalid: true` with "This field is required." A known lag affects privacy policy / acknowledgment checkboxes.
- A dropdown/choice field is correctly picked, but the invalid flag persists.
- A text field is filled correctly, but validation error text remains until the user tabs or blurs the field.
- Combobox / autocomplete fields show stale "invalid" overlays after correct selection (Greenhouse, Ashby, Workday, Lever) but submit successfully.

**Rule: Do NOT get stuck in a fill loop.** If a field value looks correct (checked=true, value="No", "Yes") but `invalidCount` is unchanged:

1. **Try Submit anyway.** The major portals (Greenhouse, Workday, Lever, Ashby) allow submission with stale validation errors as long as the underlying value is correct.
2. **If Submit is disabled**, try interacting with a nearby field (Tab, click another input) to force validation recalculation.
3. **If a checkbox still shows invalid after `set_checked`**, try clicking it directly by coordinates (`geometra_click` with x,y) instead of the label-based toggle.
4. **For combobox fields**, pick the option via `geometra_pick_listbox_option` (preferred) rather than typing — typing into comboboxes often creates a stale autocomplete overlay that blocks confirmation.

**Decision tree for "field shows invalid after fill":**

```
Is the visible value correct?
├── YES → Try Submit (preferred action)
│         If Submit disabled → Tab away and back, then try Submit
│         Still blocked → try clicking a nearby field to force recalc
└── NO → Re-fill the field using the correct field id
```

**The `invalidCount` from schema is a heuristic, not ground truth.** Always prefer direct observation of field values over the invalid count. If Submit becomes enabled, ignore any remaining invalid fields — the portal accepted the data.

**Text-field specific fix — `imeFriendly: true`.** For React-controlled text inputs, set `imeFriendly: true` on every `kind: "text"` entry inside `geometra_fill_fields.fields` or a `run_actions` `fill_fields.fields` action. It is not an action-level key. This fires composition events that clear React's internal validity state. Use it on the first fill; do not wait for a rejected submit and then replay the submission.

### Ashby Anti-Bot Spam Filter — Two Failure Classes

**Symptom:** after a form is filled cleanly (`invalidCount: 0`, all values correct) and Submit is clicked, Ashby returns: *"We couldn't submit your application. Your application submission was flagged as possible spam."*

These blocks come from two distinct root causes and require different responses:

| Class | Root cause | Recoverable in-session? | Fix |
|---|---|---|---|
| **A. React-validation lag** | programmatic text input did not fire composition events; React marks required fields internally missing even though values look correct | Only before submit | Refill with per-field `imeFriendly: true` before the first submit action. |
| **B. Server-side block** | portal rejects the session after inspecting network/browser/session signals | No (in headless) | Mark `Failed` with note "Ashby blocked session"; preserve `blockedSite` details when present and recommend manual submit from the user's own browser. |

**How to tell them apart:** if you see `invalidCount > 0` or a required-field error before submit, class A is likely—correct the text fields with `imeFriendly: true` before submitting. If every fill is clean and the spam flag appears only after submit, class B is likely; `imeFriendly` cannot change that server-side decision.

**Evidence (2026-04-19 session):**
- Class A confirmed: Supabase #793 established the composition-event fix; current workflows apply it before the first submit.
- Class B confirmed: Unstructured #786 + ClickUp #787 — both filled cleanly with per-field `imeFriendly: true`, both still spam-flagged on submit with identical "VPN / ad blockers / shared network" messaging.

**Rule — never replay a submit to test the class.** Correct class-A evidence before the first submit. After any submit action begins, follow the no-replay rule in `modes/apply.md`: inspect the same session when possible, then record the confirmed rejection or unconfirmed outcome and stop.

**Class B response — structured block detection + manual handoff.** JobForge passes `headless: true`, `browserMode: "stock"`, `blockDetection: true`, and `blockedSitePolicy: "manual-handoff"` so Geometra MCP >=1.65.0 keeps browser windows hidden and returns structured `blockedSite` metadata for challenges and access blocks. Stop on the first confirmed block, record the failed outcome, and surface the `blockedSite` / `manualHandoff` detail to the orchestrator.

**Known-block Ashby tenants (2026-04-19 empirical observations).** These tenants fired class B on every attempted submit from a headless datacenter-IP proxy. Orchestrators planning apply dispatches should assume these tenants will Fail in headless — prioritize other portals, or skip same-tenant siblings after a confirmed class B to avoid burning subagent slots:

- Vellum, Linear, Vanta, River Financial, Higharc, Trace Labs, Solace Health, Unstructured, ClickUp, Zapier, Deepgram, Ramp, WorkOS, Ashby (self-tenant), Perplexity, **Goody**, **Starbridge**, **Graphite**, **Prompt Health**, **Vantage**

**Known class-A-compatible Ashby tenants (same observations).** These tenants accepted headless submits cleanly, often with `imeFriendly: true` making the difference on the text-field subset:

- Supabase, LangChain, Poolside, Runway Financial, Sentry, Cognition

**Base rate for untested Ashby tenants (5/5 tested 2026-04-19 cycle 4 = class B).** Treat any tenant not on the class-A-compatible list as higher-risk for server-side submit blocks — still dispatch to collect the data point, but don't burn multiple sibling-role slots on the same Ashby tenant after one confirmed block.

The pattern is tenant configuration, not role or company size. Lists drift as tenants tune their anti-bot — treat as probabilistic priors, not hard rules.

**Ashby choice-group with `optionCount: 1` and no labels (Sentry pattern).** Some Ashby tenants render Yes/No work-authorization questions as `role="button" name="Application"` pill toggles where the accessibility tree exposes neither `Yes` nor `No` labels. `fill_fields` with `choiceType: "group"` silently no-ops; `geometra_click` by `id` also fails to toggle. Fix: fall back to `geometra_click` with RAW x,y coordinates at the button centers (Yes is typically the left button, No is the right). Confirmed on Sentry Staff Platform #845, 2026-04-19.

### Other Portal Failure Classes

**Typeform applications are Geometra-unsupported.** Some companies (Better Stack confirmed, 2026-04-19) route the Apply link to a Typeform wizard (`*.typeform.com/apply-*`). Typeform renders questions via a custom React/canvas layer that does NOT expose input fields to the accessibility tree — `geometra_form_schema` returns "No forms found", `geometra_query role=textbox` returns empty, blind `geometra_type` produces no semantic change. Mark `Failed` with reason "Typeform portal — Geometra unsupported" on detection; do not burn the 9-minute budget attempting blind input.

**Avature multi-step wizards have a native-`<select>` validation lag (Bloomberg pattern).** Bloomberg's careers site redirects to `bloomberg.avature.net` with a 4-step wizard. On Step 2, native `<select>` elements ("Is Current Position? / No") accept the value but keep `invalid: true` persistently — neither Tab, re-submit, nor re-pick clears it. `imeFriendly` has no effect because the field is a native `<select>`, not React-controlled text. There is no documented recovery. Mark `Failed` with reason "Avature native-select validation lag"; account creation up to that point is preserved for any future manual path. Confirmed on Bloomberg Sr SWE Auth #828, 2026-04-19.

**Cloudflare / ATS-vendor blocks on Dropbox-class portals.** Dropbox's real apply flow lives behind `happydance.website` (ATS vendor), which can return "Sorry, you have been blocked" before the form renders. `job-boards.greenhouse.io/dropbox` does not mirror — there is no public Greenhouse fallback. Symptom-wise indistinguishable from Ashby class B but at a different layer. Mark `Failed` with reason "ATS vendor Cloudflare block (happydance.website or equivalent)" and preserve `blockedSite` details when present. Confirmed on Dropbox Sr FS Product #831, 2026-04-19.

**Greenhouse OTP-on-fill variant (Instacart pattern).** Most Greenhouse OTP flows fire on Submit. A minority (Instacart Staff FoodStorm #827, 2026-04-19) fire the 8-cell security-code gate mid-fill, BEFORE the user clicks Submit. Detection: watch for an 8-cell OTP input surfacing after resume upload or the first listbox commit. Fetch from Gmail (`from:greenhouse newer_than:10m`) immediately when it appears — do not wait for Submit.

**`geometra_fill_otp` char-drop on first fill.** Occasionally `fill_otp` lands only the first character of an 8-char code (seen on Instacart, 2026-04-19). Recovery: click the first cell to focus, then re-issue `fill_otp` with `perCharDelayMs: 120`. The form usually auto-submits once all 8 cells are populated.

**Breezy portal — tenant-dependent, native `<select>`, resume-auto-parse is primary.** A subset of companies (Avantos AI, Courted, Instinct Science confirmed 2026-04-19) host applications on `*.breezy.hr` or `applytojob.com`. Empirical rules:

- **Class is per-tenant, not uniform.** Avantos (Failed 2026-04-19 #854) returned Breezy's own "It looks like maybe you've already applied to this job?" banner on a first submit — distinct failure mode from Ashby's "flagged as possible spam". Courted (Applied 2026-04-19 #855) went through cleanly on the same session. Don't pre-skip Breezy; the outcome is tenant-specific.
- **Native `<select>` elements, not React comboboxes.** `geometra_pick_listbox_option` sets the visible display but NOT the underlying form state — submit will fail with "A response is required" on every combobox. Use `geometra_select_option` with x,y + label value for every choice field on Breezy.
- **Resume-auto-parse carries the signal.** After resume upload, Breezy auto-parses work history and education into structured rows. Do NOT Add/Delete position rows via Geometra — row mutations reshuffle fieldIds mid-flow, sequential `fill_fields` calls land in wrong rows, and upstream pollution corrupts earlier positions. Trust the parsed resume and fill only Personal Details + salary.

**Mailto-apply portals — direct email via gmail-mcp `attachments`.** A subset of HN-listed companies (CoPlane, Gambit Robotics, Rinse, Digital Health Strategies confirmed 2026-04-19) don't host an ATS form — their careers page instructs sending resume by email to `founders@...` / `jobs@...` / `contact@...`. Detection: WebFetch the careers URL; if the Apply link resolves to `mailto:` or the copy reads "email your resume to …", skip Geometra entirely.

Use `gmail_send_message` with the `attachments` parameter (available from `@razroo/gmail-mcp@1.8.0`):

```
gmail_send_message({
  to: ["founders@example.com"],
  subject: "Application — Forward Deployed AI Engineer — Charlie Greenman (Austin)",
  body: "<Section G pitch, 4-8 short paragraphs>",
  attachments: [{ path: "/abs/path/to/Charlie-Greenman-CV.pdf" }]
})
```

The MCP reads the file from disk and builds multipart/mixed MIME server-side — do NOT manually base64-encode a PDF into the `raw` parameter (the inline blob exceeds tool-call argument limits for any real attachment). Subject is auto MIME-encoded for non-ASCII (em-dash, smart quotes) by the same version. For older gmail-mcp versions (< 1.8.0) the only path was a direct Gmail API POST with the stored OAuth token at `~/.gmail-mcp/credentials.json` — upgrade if you can.

Mark Applied with note `mailto portal — sent via gmail_send_message; Gmail msgId {id}`. Verify via `gmail_get_message` that the attachment intact-size matches what was on disk before writing the TSV.

### Greenhouse Bot-Detection Honeypots

Some Greenhouse tenants (Grafana Labs confirmed, 2026-04-19) inject a honeypot-style single-pick question on the application form, rendered as a listbox labeled something like "Which of the following best describes you?" with options resembling "I am a human being / I am a bot / I am a robot".

**Rule:** pick the "I am a human being" option (or whichever option is the obvious human-authentic choice). Bots that pick other options are filtered before submit. This is NOT a validation check — the field will always read back clean — but the submit will be silently discarded if the wrong option is selected.

If the honeypot question is absent, skip. If present, always pick the human option.

### Nested Scroll Containers (Greenhouse / Ashby)

The major ATS portals (Greenhouse, Workday, Lever, Ashby) use nested scrollable regions. A field's `visibleBounds` may show it as off-screen even when it is actually visible within a child scroll container. Geometra's `scroll_to` operates on the outermost page scroll, so it cannot reach fields in inner scroll regions.

**Signs you are dealing with nested scroll:**
- `scroll_to` reports `revealed: false` with `maxSteps` exhausted, but you can see the field in the page model
- A field's `y` coordinate in `bounds` is far outside the viewport, yet it is visible on screen
- Wheel events at one `y` coordinate scroll a different region than expected

**Workaround:**
1. Use `geometra_wheel` at a low `y` value (e.g., 360, near the top of the viewport) to scroll the outer container
2. Alternatively, click directly on the element using `geometra_click` with x,y coordinates derived from the element's `visibleBounds` center
3. Once in the correct scroll region, `scroll_to` within that region works correctly

### Corrupted Fields (Text Typed Into Listbox)

Sometimes text typed into the wrong field (e.g., an essay pasted into a listbox search field) corrupts the field state. The listbox shows the typed text as a search query and refuses to clear.

**Recovery:**
1. Find and click the "Clear selections" button (`role: "button"`, `name: "Clear selections"`) — this usually resets the field
2. After clearing, use `geometra_pick_listbox_option` to select the correct value
3. If "Clear selections" is not available, try pressing `Escape` multiple times or clicking outside the dropdown

### Parallel Form Submissions — Isolated Sessions Required

When running multiple application forms in parallel, each `geometra_connect` MUST use `isolated: true`. Without this flag, sessions share the Chromium browser pool and contaminate each other's localStorage, cookies, and autocomplete state — one job's email address can leak into another job's form.

**Correct parallel pattern:**
```javascript
geometra_connect({
  pageUrl: "https://...",
  isolated: true,
  headless: true,
  slowMo: 350,
  browserMode: "stock",
  blockDetection: true,
  blockedSitePolicy: "manual-handoff"
})
```

**Wrong:** running `geometra_connect` without `isolated: true` when submitting multiple forms concurrently. The forms may share state and produce incorrect submissions.

**With a configured proxy,** add `proxy: { server, username?, password?, bypass? }` to the same call — see "BYO Proxy + Block Detection" below. The reusable-proxy pool is partitioned by proxy identity, so mixing direct and proxied sessions across parallel rounds is safe. Keep `headless: true`, `browserMode: "stock"`, `blockDetection: true`, and `blockedSitePolicy: "manual-handoff"` either way so JobForge keeps browser windows hidden and surfaces structured blocked-site states.

### Session Reuse — Exact IDs in One Live MCP Registry

Geometra MCP 1.65 routes sessions through the current server process, not through conversation history. Any agent sharing that live MCP server can address an existing session only by passing its exact `sessionId`; a fresh agent context does not imply a fresh or private session registry.

If an id is stale, belongs to a restarted MCP process, or is otherwise absent, Geometra returns an explicit `session_not_found` error. It does not silently return an empty page. An MCP restart destroys the old registry, so neither the orchestrator nor a subagent can recover that id; open a new isolated session instead.

**Rule:** default to a new `isolated: true` session per worker. Attach to an existing session only when the task explicitly supplies its exact id and that id still exists in `geometra_list_sessions`. Pass it on every call. Never rely on conversation ownership or implicit "most recent" routing.

### Stale Session Cleanup — MANDATORY

**Problem in one sentence:** if a previous subagent aborted (ran out of context, timed out, hit a tool error), its Chromium session can remain in the shared Geometra MCP registry and consume resources or confuse later routing.

**Fix in one sentence:** at an orchestrator barrier with no workers in flight, run `geometra_list_sessions` and disconnect every returned `sessions[].id`; each worker then opens one isolated session and never cleans peer ids.

If Geometra MCP itself disappears or becomes unresponsive, inspect `.jobforge-mcp/geometra-mcp.jsonl` before escalating. `signal_received` means the MCP host or parent process sent a catchable signal, `child_exit` means the Geometra child exited, `child_stderr` preserves stderr that may not show in the agent transcript, and a final stale `heartbeat` with no later event usually means SIGKILL / host reap / OS kill. Report the final event type and timestamp rather than saying only "MCP crashed."

---

#### Rule 1 — Orchestrator pre-dispatch cleanup (DO THIS EVERY TIME)

Before dispatching ANY batch of subagents that will use Geometra (apply, scan, pipeline, batch, auto-pipeline), run this cleanup sequence only after every worker in the prior round has returned:

```
Step 1:  geometra_list_sessions()
Steps 2..N, once for every returned sessions[].id:
         geometra_disconnect({ sessionId: "<id>", closeBrowser: true })
         # If sessions[] is empty, cleanup is complete.
```

**DO NOT** think about whether cleanup is needed. **DO NOT** keep a listed session because it looks "fine". Disconnect every returned id before `task` dispatch. Geometra MCP 1.65 uses a process-shared registry and exact-id disconnect; the barrier matters because disconnecting a listed id while a worker is active can close that peer's live session. An empty list is already clean.

**Then** dispatch your subagents.

**Single exception:** for an interactive single-application handoff, if the exact `sessionId` is confirmed present in the current live MCP registry and the task explicitly tells one subagent to attach to it, skip cleanup and pass that id. Conversation ownership is irrelevant; registry presence plus the explicit id is the gate.

---

#### Rule 2 — Subagent isolated connect (DO THIS EVERY TIME)

Every subagent that uses Geometra must make this its first Geometra call:

```
geometra_connect({ pageUrl: "<the URL the orchestrator gave you>", isolated: true, headless: true, slowMo: 350, browserMode: "stock", blockDetection: true, blockedSitePolicy: "manual-handoff" })
# Save the returned sessionId and pass it to every subsequent call.
```

**If the orchestrator says proxy is configured,** read the top-level
`proxy:` block from `config/profile.yml` and add it to the connect call:

```
geometra_connect({
           pageUrl: "<URL>", isolated: true, headless: true, slowMo: 350,
           browserMode: "stock", blockDetection: true, blockedSitePolicy: "manual-handoff",
           proxy: { server: "...", username: "...", password: "...", bypass: "..." }
         })
```

Pass the proxy object through unchanged. Do NOT paraphrase or drop fields — `username`/`password`/`bypass` are optional, so only include what exists in `config/profile.yml`. Do not echo proxy credentials in status text. See the "BYO Proxy + Block Detection" reference section for the why.

**DO NOT** list and disconnect every session from inside a worker. Two workers may be live concurrently, and the list is shared. When this worker reaches a confirmed terminal state, disconnect only its own exact returned id after capturing evidence. On an ambiguous outcome, inspect that same session before cleanup or handoff.

**Single exception:** if the orchestrator's task prompt says literally "attach to sessionId X" or "use existing session X", skip connect and call `geometra_page_model({ sessionId: "X" })` directly; keep passing `X` explicitly.

---

#### Rule 3 — Routing high-value applications

When the orchestrator dispatches an `apply` (form-fill + submit), pick the subagent based on this table:

| Offer score | Subagent |
|-------------|----------|
| 3.0-3.9/5 | `@general-free` |
| 4.0+/5 | `@general-paid` |
| User said "top-tier", "dream job", "high-stakes" | `@general-paid` |
| Late-stage pipeline (already passed screens) | `@general-paid` |

**Why:** form-fill flows are 6+ steps. Free-tier models have smaller context windows and sometimes abort mid-flow when the form schema is large (Greenhouse, Workday). Paid tier has more headroom. Evaluation and procedural non-apply work stay on `@general-free` — only the `apply` step gets upgraded.
