5 of 123 min

The hardest part of auto-apply isn't submit

From the outside auto-apply looks deceptively simple.

Open a vacancy. Paste a letter. Press submit.

In practice submit is hardly a problem.

The problem is something else: the application page on HH isn’t one and the same page. It can mean completely different system states: the wrong resume with no way to switch, an application already sent, an expired session, a questionnaire, radio, checkbox, select, mixed forms with an extra text field, or simply a state that can’t be automated safely.

That’s why the apply flow in a project like this begins not with an action but with recognising reality.

def classify_page_variant(snapshot: Dict[str, Any]) -> PageVariant:
    if not isinstance(snapshot, dict):
        return PageVariant.UNKNOWN

    if snapshot.get("auth_required"):
        return PageVariant.AUTH_REQUIRED

    if snapshot.get("already_applied"):
        return PageVariant.ALREADY_APPLIED

    if snapshot.get("hh_resume_override"):
        return PageVariant.FIXED_RESUME

    questionnaire_required = bool(snapshot.get("questionnaire_required"))
    if questionnaire_required:
        items = list(snapshot.get("questionnaire_items") or [])
        safe_types = ApplyFlow.SAFE_QUESTIONNAIRE_TEXT_TYPES
        has_unsupported = any(
            str(item.get("field_type") or "input").strip().lower() not in safe_types
            or bool(item.get("requires_human"))
            for item in items
            if isinstance(item, dict)
        )
        if has_unsupported or not items:
            return PageVariant.QUESTIONNAIRE_UNSUPPORTED
        return PageVariant.QUESTIONNAIRE_TEXT

    if snapshot.get("primary_submit_label") and not questionnaire_required:
        return PageVariant.SIMPLE_PICKABLE

    return PageVariant.UNKNOWN

classify_page_variant(…) is needed for the first decision: what page is in front of the system now. not isinstance(snapshot, dict) cuts off a broken snapshot so the project doesn’t make decisions on garbage. AUTH_REQUIRED stops the pipeline if a login wall opens instead of an application. ALREADY_APPLIED keeps the system from making duplicates where an application already exists. FIXED_RESUME catches the case where HH itself imposes a resume and doesn’t allow a proper switch. questionnaire_required moves the page into another class of difficulty where the simple submit path no longer works. items = list(…) gathers a structured view of the questionnaire, without which automation goes blind. safe_types limits the set of fields the system considers safe to process at all. has_unsupported checks whether it has met a form where confidence is no longer sufficient. field_type not in safe_types cuts off everything outside the current safe circuit. requires_human gives the form the right to bring a person into the circuit early if automation is already doubtful. QUESTIONNAIRE_UNSUPPORTED is there so the project honestly admits the limit of its confidence and goes no further. QUESTIONNAIRE_TEXT records the case where the questionnaire can still follow a controlled scenario. primary_submit_label checks whether there’s a direct path to applying without a questionnaire. SIMPLE_PICKABLE marks the happy path where the page can be processed almost linearly. UNKNOWN leaves the system the right not to act if the page state isn’t recognised.

That’s where the real difficulty lies.

Here the project doesn’t “press a button”. Here it first interprets the state of the page and only then chooses one of the paths: automatic submit, questionnaire path, handoff to a person, skip, or stop.

At some point it became clear that questionnaires aren’t a special case but a separate machine inside the project. The system has to do more than paste text: it has to distinguish form types, understand the limits of its confidence and not act where the page is no longer clear.

So auto-apply turned out not to be the task of “pressing a button instead of a person”.

It turned out to be the task of “understanding what form is in front of you before taking a single action”.

Series contents
  1. Automating applications as an answer to market asymmetry
  2. How the bot compares me with a vacancy and assembles an application for it
  3. Why the closure of the job seeker API didn't kill the project but made it grow up
  4. How I kept the system's brain and replaced its transport
  5. The hardest part of auto-apply isn't submit
  6. Auto-apply in visible mode
  7. How the LLM in my project turns a vacancy into a cover letter
  8. Why full autonomy turned out weaker than managed autonomy
  9. I gave the system not only actions but also brakes
  10. How the project lives after applying: statuses, invitations, conversations and a manual dashboard
  11. Dashboard
  12. The most unexpected vacancy my bot found