npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jobo-ai/autoapply-sdk

v1.0.0

Published

AutoApply SDK for building Jobo providers — like Apify actors for job application automation

Readme

@jobo/sdk

TypeScript SDK for building Jobo AutoApply providers — like Apify actors, but for job application automation.

npm TypeScript License: MIT


What Is This?

The Jobo SDK lets you build providers — small TypeScript packages that know how to fill out job application forms on a specific ATS (Applicant Tracking System) like Ashby, Greenhouse, Lever, Workday, etc.

Each provider:

  1. Discovers form fields on a page (text inputs, dropdowns, radios, file uploads, etc.)
  2. Fills each field with the correct value from a candidate's profile
  3. Submits the application

The compiled JavaScript runs inside a Playwright browser controlled by the Jobo orchestrator, which handles navigation, LLM-based answer generation, and multi-step form flows.


Architecture

@jobo/sdk
├── Core Types          — FormFieldInfo, SetFieldResult, ProviderManifest, etc.
├── Runtime             — Browser-context helpers (DOM, React compat, fuzzy matching)
│   ├── BaseProvider    — Abstract provider class with handler registration
│   ├── BaseHandler     — Abstract field handler with common utilities
│   └── helpers         — simulateTyping, setReactValue, reactClick, findBestOption, etc.
├── Build               — esbuild-based compiler (TS → browser JS)
├── Testing             — Playwright-based live page test runner
└── CLI                 — jobo init | build | test | publish

How It Fits Together

┌─────────────────────────────────────────────────────────────────┐
│  Your Provider (TypeScript)                                     │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │ manifest.json│  │ provider.ts  │  │ handlers/            │  │
│  │ (metadata)   │  │ (orchestrate)│  │  email.ts, radio.ts  │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
├─────────────────────────────────────────────────────────────────┤
│  @jobo/sdk/runtime  (BaseProvider, BaseHandler, helpers)        │
├─────────────────────────────────────────────────────────────────┤
│  jobo build  →  Compiled JS (esbuild, browser-ready IIFE)       │
├─────────────────────────────────────────────────────────────────┤
│  jobo publish  →  Jobo Registry (MongoDB, loaded at runtime)    │
├─────────────────────────────────────────────────────────────────┤
│  C# Orchestrator (Playwright)                                   │
│  Navigate → Load scripts → getFields() → LLM → setField()      │
│  → detectNextStep() → submitForm() → isCompleted()              │
└─────────────────────────────────────────────────────────────────┘

Quick Start

1. Scaffold a new provider

npx @jobo/sdk init my-ats
cd jobo-provider-my-ats
npm install

This creates a ready-to-build project with a provider, a text handler, a manifest, and test config.

2. Implement your provider

jobo-provider-my-ats/
├── src/
│   ├── manifest.json       # Provider metadata (id, name, URL patterns)
│   ├── provider.ts         # Main provider class (extends BaseProvider)
│   ├── helpers.ts           # ATS-specific selectors & utilities
│   └── handlers/
│       ├── text.ts          # Text input handler (extends BaseHandler)
│       ├── email.ts         # Email field handler
│       ├── radio.ts         # Radio button handler
│       ├── select.ts        # Dropdown select handler
│       └── ...              # One handler per field type
├── package.json
├── tsconfig.json
└── test.config.json         # Test data for live page testing

3. Build

npx jobo build                # Per-file compilation (recommended)
npx jobo build --bundle       # Single-file bundle
npx jobo build --watch        # Watch mode for development
npx jobo build --minify       # Minified production build

4. Test against a live page

npx jobo test --url https://jobs.ashbyhq.com/company/job-id/application

This launches a real browser, loads your provider, discovers fields, and fills them with test data.

5. Publish to the Jobo registry

npx jobo publish --dry-run    # Validate without publishing
npx jobo publish              # Publish to Jobo registry

jobo publish now runs a pre-publish build automatically (bundle mode by default), so a separate jobo build step is optional.


Creating a Provider — Step by Step

Step 1: The Manifest (manifest.json)

Every provider needs a manifest.json in src/ that describes it:

{
  "providerId": "my-ats",
  "displayName": "My ATS",
  "urlPatterns": ["apply.my-ats.com", "careers.my-ats.com"],
  "jsProviderName": "MyAtsProvider",
  "runtimeConfig": {
    "applyUrlSuffix": "application",
    "pageSettleDelayMs": 2000,
    "validationErrorSelector": ".error-message",
    "resumeInputSelectors": ["input[type='file']"]
  }
}

| Field | Required | Description | | ---------------- | -------- | --------------------------------------------------------------------------------------------------- | | providerId | Yes | Unique identifier (lowercase, kebab-case). | | displayName | Yes | Human-readable name shown in the UI. | | urlPatterns | Yes | Domain patterns that identify this ATS. The orchestrator uses these to route URLs to your provider. | | jsProviderName | No | Global variable name for the provider instance. Defaults to {providerId}Provider. | | runtimeConfig | No | Configuration the C# host reads at runtime (see table below). |

Runtime Config Options

| Option | Type | Default | Description | | ------------------------- | ------------------------ | ------- | ------------------------------------------------------------------- | | pageSettleDelayMs | number | 0 | Delay after page navigation before interacting. | | validationErrorSelector | string | — | CSS selector for validation error elements. | | initializeMethod | string | — | Method name on the provider to call during initialization. | | applyUrlSuffix | string | — | URL suffix to append to apply URLs (e.g., "application"). | | applyUrlSkipIfContains | string[] | — | Skip appending suffix if URL already contains any of these strings. | | resumeInputSelectors | string[] | — | CSS selectors for resume file input elements. | | jsTypeToFieldType | Record<string, string> | — | Map JS handler type strings to C# FieldType enum names. | | fieldTypeToJsHandler | Record<string, string> | — | Map C# FieldType enum names to JS handler type strings. |

Step 2: The Provider Class (provider.ts)

Extend BaseProvider and implement the required abstract methods:

import {
  BaseProvider,
  reactClick,
  delay,
  isElementVisible,
} from "@jobo/sdk/runtime";
import type { NextStepDetection, ApplyStepResult } from "@jobo/sdk";
import { MY_ATS_SELECTORS } from "./helpers";

// Import handlers — they auto-register themselves on the provider
import "./handlers/email";
import "./handlers/text";
import "./handlers/radio";

class MyAtsProviderImpl extends BaseProvider {
  readonly name = "MyAts";
  readonly version = "1.0.0";

  // Map handler type → compiled JS path (relative to dist/)
  readonly handlerScripts: Record<string, string> = {
    email: "handlers/email.js",
    text: "handlers/text.js",
    radio: "handlers/radio.js",
  };

  /**
   * Return true if this provider can handle the current page.
   * Called first — if false, the orchestrator tries other providers.
   */
  canHandle(): boolean {
    return !!document.querySelector(MY_ATS_SELECTORS.formContainer);
  }

  /**
   * Return true if the job posting is expired / no longer accepting applications.
   */
  isExpired(): boolean {
    return !!document.querySelector(".job-closed-message");
  }

  /**
   * Return true if the application was already submitted successfully.
   */
  isCompleted(): boolean {
    return !!document.querySelector(".application-success");
  }

  /**
   * Return true if there are visible validation errors on the form.
   */
  hasErrors(): boolean {
    const errors = document.querySelectorAll(".field-error:not(:empty)");
    return Array.from(errors).some((el) => isElementVisible(el));
  }

  /**
   * Detect what action is available: submit the form or go to the next step.
   */
  detectNextStep(): NextStepDetection {
    const submitBtn = document.querySelector(MY_ATS_SELECTORS.submitButton);
    if (submitBtn && isElementVisible(submitBtn)) {
      return { status: "submit_pending" };
    }

    const nextBtn = document.querySelector(".next-step-button");
    if (nextBtn && isElementVisible(nextBtn)) {
      return { status: "next_pending" };
    }

    return { status: "submit_pending" };
  }

  /**
   * If the user lands on a job listing page (not the apply form),
   * return the URL to redirect to. Return null if already on the form.
   */
  getRedirectUrl(): string | null {
    const applyLink = document.querySelector("a.apply-button");
    if (applyLink) {
      return (applyLink as HTMLAnchorElement).href;
    }
    return null;
  }

  /**
   * Optional: Custom initialization after page load.
   * Use for cookie consent, login checks, modal dismissal, etc.
   */
  async initializeForAutoApply(): Promise<ApplyStepResult> {
    // Dismiss cookie banner if present
    const cookieBtn = document.querySelector(".cookie-accept");
    if (cookieBtn) {
      reactClick(cookieBtn);
      await delay(500);
    }
    return { success: true, status: "form_ready" };
  }
}

// Create singleton and register as global (required for C# host)
const MyAtsProvider = new MyAtsProviderImpl();

if (typeof window !== "undefined") {
  (window as unknown as Record<string, unknown>)["MyAtsProvider"] =
    MyAtsProvider;
}
if (typeof globalThis !== "undefined") {
  (globalThis as unknown as Record<string, unknown>)["MyAtsProvider"] =
    MyAtsProvider;
}

export default MyAtsProvider;

Required Methods

| Method | Returns | Purpose | | ------------------ | ------------------- | ------------------------------------------- | | canHandle() | boolean | Does this provider handle the current page? | | isExpired() | boolean | Is the job posting expired? | | isCompleted() | boolean | Was the application already submitted? | | hasErrors() | boolean | Are there visible validation errors? | | detectNextStep() | NextStepDetection | What action is available (submit or next)? | | getRedirectUrl() | string \| null | Redirect URL if not on the apply form. |

Optional Methods

| Method | Returns | Purpose | | -------------------------- | -------------------------- | ---------------------------------------------------- | | initializeForAutoApply() | Promise<ApplyStepResult> | Custom setup after page load. | | submitForm() | Promise<SetFieldResult> | Custom submit logic (default: clicks submit button). |

Step 3: ATS-Specific Helpers (helpers.ts)

Centralize all ATS-specific CSS selectors and utility functions:

import { isElementVisible } from "@jobo/sdk/runtime";

/**
 * All CSS selectors for this ATS in one place.
 * Makes it easy to update when the ATS changes their markup.
 */
export const MY_ATS_SELECTORS = {
  // Form structure
  formContainer: ".application-form",
  fieldEntry: ".form-field-group",
  questionTitle: ".field-label",

  // Buttons
  submitButton: 'button[type="submit"].apply-btn',
  nextButton: ".next-step-button",

  // Status
  successMessage: ".application-success",
  errorMessage: ".field-error",

  // System fields
  emailInput: 'input[type="email"]',
  phoneInput: 'input[type="tel"]',
  resumeInput: 'input[type="file"][accept*="pdf"]',
} as const;

/**
 * Find the field container for an input element.
 * Most ATS wrap each field in a container div with a label.
 */
export function findFieldContainer(element: Element): Element | null {
  return element.closest(MY_ATS_SELECTORS.fieldEntry);
}

/**
 * Get the question/label text from a field container.
 */
export function getQuestionText(container: Element): string {
  const label = container.querySelector(MY_ATS_SELECTORS.questionTitle);
  return label?.textContent?.replace("*", "").replace("✱", "").trim() || "";
}

Step 4: Field Handlers (handlers/*.ts)

Each handler is responsible for one type of form field. It must:

  1. Discover all fields of that type on the page (discoverFields)
  2. Fill a specific field with a value (fillField)

Text Input Handler

import { BaseHandler } from "@jobo/sdk/runtime";
import type { FormFieldInfo, SetFieldResult } from "@jobo/sdk";
import {
  MY_ATS_SELECTORS,
  findFieldContainer,
  getQuestionText,
} from "../helpers";
import provider from "../provider";

class TextHandler extends BaseHandler {
  constructor() {
    super({
      type: "text",
      // Override the default container selector for this ATS
      containerSelector: MY_ATS_SELECTORS.fieldEntry,
      labelSelector: MY_ATS_SELECTORS.questionTitle,
    });
  }

  discoverFields(): FormFieldInfo[] {
    const fields: FormFieldInfo[] = [];
    const inputs = document.querySelectorAll(
      'input[type="text"], input:not([type])',
    );

    for (const input of inputs) {
      if (!this.isVisible(input)) continue;

      const container = findFieldContainer(input);
      if (!container) continue;

      // tagContainer assigns a unique data-field-id and returns it
      const fieldId = this.tagContainer(container);
      const question = getQuestionText(container);
      if (!question) continue;

      fields.push({
        id: fieldId,
        type: "text",
        question,
        isRequired: this.isRequired(input as HTMLInputElement),
        selector: `[data-field-id="${fieldId}"] input`,
      });
    }

    return fields;
  }

  async fillField(
    fieldId: string,
    value: string | boolean,
  ): Promise<SetFieldResult> {
    const container = this.findContainerByFieldId(fieldId);
    if (!container)
      return { success: false, error: `Container not found: ${fieldId}` };

    const input = container.querySelector("input") as HTMLInputElement | null;
    if (!input) return { success: false, error: "Input not found" };

    // setReactValue works with React 15–18+ controlled inputs
    const result = this.setReactValue(input, String(value));
    if (!result.success) {
      // Fallback: simulate character-by-character typing
      await this.simulateTyping(input, String(value));
    }

    return { success: true };
  }
}

// Auto-register on the provider instance
provider.registerHandler("text", new TextHandler());

Select/Dropdown Handler

import {
  BaseHandler,
  reactClick,
  delay,
  findBestOption,
} from "@jobo/sdk/runtime";
import type { FormFieldInfo, SetFieldResult } from "@jobo/sdk";
import { findFieldContainer, getQuestionText } from "../helpers";
import provider from "../provider";

class SelectHandler extends BaseHandler {
  constructor() {
    super({ type: "select" });
  }

  discoverFields(): FormFieldInfo[] {
    const fields: FormFieldInfo[] = [];
    const selects = document.querySelectorAll("select");

    for (const select of selects) {
      if (!this.isVisible(select)) continue;

      const container = findFieldContainer(select);
      if (!container) continue;

      const fieldId = this.tagContainer(container);
      const question = getQuestionText(container);
      if (!question) continue;

      // Collect available options
      const options = Array.from(select.options)
        .filter((opt) => opt.value && !opt.disabled)
        .map((opt) => opt.text.trim());

      fields.push({
        id: fieldId,
        type: "select",
        question,
        isRequired: this.isRequired(select),
        options,
        selector: `[data-field-id="${fieldId}"] select`,
      });
    }

    return fields;
  }

  async fillField(
    fieldId: string,
    value: string | boolean,
  ): Promise<SetFieldResult> {
    const container = this.findContainerByFieldId(fieldId);
    if (!container)
      return { success: false, error: `Container not found: ${fieldId}` };

    const select = container.querySelector(
      "select",
    ) as HTMLSelectElement | null;
    if (!select) return { success: false, error: "Select not found" };

    const targetValue = String(value).trim();
    const optionTexts = Array.from(select.options).map((o) => o.text.trim());

    // findBestOption uses fuzzy matching (Jaro-Winkler) to find the closest match
    const bestMatch = findBestOption(optionTexts, targetValue);
    if (!bestMatch)
      return {
        success: false,
        error: `No matching option for "${targetValue}"`,
      };

    const matchingOption = Array.from(select.options).find(
      (o) => o.text.trim() === bestMatch,
    );
    if (!matchingOption)
      return { success: false, error: "Option element not found" };

    select.value = matchingOption.value;
    select.dispatchEvent(new Event("change", { bubbles: true }));
    await delay(100);

    return { success: true };
  }
}

provider.registerHandler("select", new SelectHandler());

Radio Button Handler

import {
  BaseHandler,
  generateGUID,
  reactClick,
  delay,
  findBestOption,
} from "@jobo/sdk/runtime";
import type { FormFieldInfo, SetFieldResult } from "@jobo/sdk";
import provider from "../provider";

class RadioHandler extends BaseHandler {
  constructor() {
    super({ type: "radio" });
  }

  discoverFields(): FormFieldInfo[] {
    const fields: FormFieldInfo[] = [];
    const fieldsets = document.querySelectorAll("fieldset");

    for (const fieldset of fieldsets) {
      const radios = fieldset.querySelectorAll('input[type="radio"]');
      if (radios.length === 0) continue;
      if (fieldset.getAttribute("data-field-id")) continue;

      // Get the question text (usually the legend or a label)
      const question =
        fieldset.querySelector("legend")?.textContent?.trim() ||
        fieldset.querySelector("label")?.textContent?.trim() ||
        "";

      // Collect option labels
      const options: string[] = [];
      for (const radio of radios) {
        const label = fieldset.querySelector(
          `label[for="${(radio as HTMLInputElement).id}"]`,
        );
        const text = label?.textContent?.trim();
        if (text && !options.includes(text)) options.push(text);
      }

      const fieldId = generateGUID();
      fieldset.setAttribute("data-field-id", fieldId);

      fields.push({
        id: fieldId,
        type: "radio",
        question: question.replace("*", "").replace("✱", "").trim(),
        isRequired: Array.from(radios).some(
          (r) => (r as HTMLInputElement).required,
        ),
        options,
      });
    }

    return fields;
  }

  async fillField(
    fieldId: string,
    value: string | boolean,
  ): Promise<SetFieldResult> {
    const container = document.querySelector(`[data-field-id="${fieldId}"]`);
    if (!container)
      return { success: false, error: `Container not found: ${fieldId}` };

    const radios = container.querySelectorAll('input[type="radio"]');
    const targetValue = String(value).trim();

    // Use fuzzy matching to find the best option
    const optionTexts: string[] = [];
    for (const radio of radios) {
      const label = container.querySelector(
        `label[for="${(radio as HTMLInputElement).id}"]`,
      );
      optionTexts.push(label?.textContent?.trim() || "");
    }

    const bestMatch = findBestOption(optionTexts, targetValue);
    if (!bestMatch)
      return {
        success: false,
        error: `No matching option for "${targetValue}"`,
      };

    const matchIndex = optionTexts.indexOf(bestMatch);
    const targetRadio = radios[matchIndex] as HTMLInputElement;

    // Try label click first (React often attaches handlers to labels)
    const label = container.querySelector(`label[for="${targetRadio.id}"]`);
    if (label) {
      reactClick(label);
      await delay(100);
      if (targetRadio.checked) return { success: true };
    }

    // Fallback: click the radio directly
    reactClick(targetRadio);
    await delay(100);
    return { success: true };
  }
}

provider.registerHandler("radio", new RadioHandler());

File Upload Handler (Resume)

File uploads cannot be done from JavaScript — they require Playwright's native file API. Return a special result to signal this:

import { BaseHandler, generateGUID, isElementVisible } from "@jobo/sdk/runtime";
import type { FormFieldInfo, SetFieldResult } from "@jobo/sdk";
import { MY_ATS_SELECTORS, findFieldContainer } from "../helpers";
import provider from "../provider";

class ResumeHandler extends BaseHandler {
  constructor() {
    super({ type: "resume" });
  }

  discoverFields(): FormFieldInfo[] {
    const fields: FormFieldInfo[] = [];
    const fileInput = document.querySelector(MY_ATS_SELECTORS.resumeInput);

    if (fileInput && isElementVisible(fileInput.parentElement || fileInput)) {
      const container =
        findFieldContainer(fileInput) || fileInput.parentElement;
      if (container && !container.getAttribute("data-field-id")) {
        const fieldId = generateGUID();
        container.setAttribute("data-field-id", fieldId);

        fields.push({
          id: fieldId,
          type: "resume",
          question: "Resume/CV",
          isRequired: true,
          selector: MY_ATS_SELECTORS.resumeInput,
        });
      }
    }

    return fields;
  }

  async fillField(
    _fieldId: string,
    _value: string | boolean,
  ): Promise<SetFieldResult> {
    // Signal to the C# host that Playwright must handle this field
    return {
      success: false,
      error: "RESUME_REQUIRES_PLAYWRIGHT",
      requiresPlaywright: true,
      selector: MY_ATS_SELECTORS.resumeInput,
    };
  }
}

provider.registerHandler("resume", new ResumeHandler());

Typeahead/Autocomplete Handler

Typeahead fields require typing to trigger a dropdown, then selecting from the results:

import {
  BaseHandler,
  generateGUID,
  simulateTyping,
  delay,
  waitForElement,
  findBestOption,
  reactClick,
} from "@jobo/sdk/runtime";
import type { FormFieldInfo, SetFieldResult } from "@jobo/sdk";
import { findFieldContainer } from "../helpers";
import provider from "../provider";

class TypeaheadHandler extends BaseHandler {
  constructor() {
    super({ type: "typeahead" });
  }

  discoverFields(): FormFieldInfo[] {
    const fields: FormFieldInfo[] = [];
    const comboboxes = document.querySelectorAll('input[role="combobox"]');

    for (const combobox of comboboxes) {
      const container = findFieldContainer(combobox);
      if (!container || container.getAttribute("data-field-id")) continue;

      const fieldId = generateGUID();
      container.setAttribute("data-field-id", fieldId);

      const question =
        container.querySelector("label")?.textContent?.trim() || "";
      if (!question) continue;

      fields.push({
        id: fieldId,
        type: "typeahead",
        question: question.replace("*", "").replace("✱", "").trim(),
        isRequired: (combobox as HTMLInputElement).required,
        selector: `[data-field-id="${fieldId}"] input[role="combobox"]`,
      });
    }

    return fields;
  }

  async fillField(
    fieldId: string,
    value: string | boolean,
  ): Promise<SetFieldResult> {
    const container = document.querySelector(`[data-field-id="${fieldId}"]`);
    if (!container)
      return { success: false, error: `Container not found: ${fieldId}` };

    const input = container.querySelector(
      'input[role="combobox"]',
    ) as HTMLInputElement;
    if (!input) return { success: false, error: "Combobox input not found" };

    const text = String(value).trim();

    // Type slowly to trigger the dropdown (paste: false = char by char)
    input.focus();
    await simulateTyping(input, text, {
      paste: false,
      blur: false,
      typingDelay: 30,
    });
    await delay(500);

    // Wait for dropdown options to appear
    const controlId = input.getAttribute("aria-controls");
    if (!controlId) return { success: true };

    try {
      await waitForElement(`#${controlId} [role="option"]`, 3000);
    } catch {
      return { success: true }; // No dropdown appeared, value was accepted as-is
    }

    // Select the best matching option
    const listbox = document.querySelector(`#${controlId}`);
    if (!listbox) return { success: true };

    const optionEls = Array.from(listbox.querySelectorAll('[role="option"]'));
    const options = optionEls
      .map((el) => el.textContent?.trim())
      .filter(Boolean) as string[];
    const bestMatch = findBestOption(options, text) || options[0];

    if (bestMatch) {
      const targetEl = optionEls.find(
        (el) => el.textContent?.trim() === bestMatch,
      );
      if (targetEl) {
        reactClick(targetEl);
        await delay(100);
      }
    }

    return { success: true };
  }
}

provider.registerHandler("typeahead", new TypeaheadHandler());

SDK Modules Reference

@jobo/sdk — Core Types

import type {
  // Field discovery
  FormFieldInfo, // Discovered field metadata (id, type, question, options, etc.)
  SetFieldResult, // Result of setting a field value
  FieldType, // Enum: text, textarea, select, radio, checkbox, file, date, etc.
  FieldOption, // Option in a select/radio/checkbox (value, text, isSelected)
  FieldConstraints, // Validation constraints (minLength, maxLength, pattern, etc.)

  // Provider interface
  AutoApplyProvider, // Full provider interface
  FieldHandler, // Handler interface (getFields, setField)
  ProviderManifest, // manifest.json schema
  RuntimeConfig, // Runtime configuration options

  // Flow control
  ApplyFlowStatus, // form_ready, submit_ready, submitted, login_required, etc.
  ApplyStepResult, // Step result with status and errors
  NextStepDetection, // submit_pending, next_pending, completed, error
  ValidationError, // Form validation error (fieldId, message)
} from "@jobo/sdk";

// Enum value (not just the type)
import { FieldTypeEnum } from "@jobo/sdk";

@jobo/sdk/runtime — Browser Helpers

These utilities run in the browser context (injected via Playwright). Import them in your provider and handler code.

import {
  // Base classes
  BaseProvider, // Abstract provider — extend this
  BaseHandler, // Abstract handler — extend this

  // GUID generation
  generateGUID, // Generate a unique field tracking ID

  // Element visibility
  isElementVisible, // Check if element is visible (not display:none, etc.)
  hasVisibleElements, // Check if selector matches any visible elements

  // React-compatible value setting
  setReactValue, // Set value using native setter (React 15–18+ compatible)
  setReactValueWithoutBlur, // Same but keeps focus (for typeaheads)

  // Click simulation
  reactClick, // Full mousedown → mouseup → click sequence
  simulateClick, // Async click with delay
  simulateEnter, // Simulate Enter keypress

  // Typing simulation
  simulateTyping, // Type or paste text with full event sequence

  // Wait utilities
  waitForElement, // Wait for element to appear (MutationObserver)
  waitForRemoval, // Wait for element to be removed
  waitForAttribute, // Wait for attribute to have a value
  delay, // Simple setTimeout promise

  // Selector utilities
  getUniqueSelector, // Generate unique CSS selector for an element
  deepQuerySelector, // Search through shadow DOM
  deepQuerySelectorAll, // Search all through shadow DOM

  // String matching (fuzzy)
  jaroWinklerSimilarity, // Jaro-Winkler similarity score (0–1)
  areStringsSimilar, // Word-by-word similarity check
  normalizeForMatching, // Strip punctuation, lowercase, normalize whitespace
  allWordsMatch, // Check if all answer words appear in option
  findBestOption, // Find best matching option from a list

  // Label utilities
  findLabel, // Find label for an input (for=, parent, aria-labelledby)
  getLabelText, // Get label text for an input
} from "@jobo/sdk/runtime";

// Type exports
import type { HandlerConfig, TypingOptions } from "@jobo/sdk/runtime";

Key Helper Details

setReactValue(element, value) — Sets a value on an input using the native property descriptor, then dispatches input and change events. This bypasses React's synthetic event system and works with controlled components in React 15–18+.

simulateTyping(element, text, options?) — Simulates user typing with full keyboard event sequence. Options:

  • paste: true (default) — Sets value instantly with insertFromPaste event
  • paste: false — Types character by character with typingDelay ms between keystrokes
  • blur: true (default) — Blurs the element after typing

findBestOption(options, answer, threshold?) — Multi-strategy fuzzy matching:

  1. Exact match (case-insensitive)
  2. Answer is substring of option
  3. Option is substring of answer
  4. All words from answer appear in option
  5. Normalized substring match
  6. Jaro-Winkler fuzzy match (default threshold: 0.9)

reactClick(element) — Dispatches mousedownmouseupclick with bubbles: true. Works better than element.click() with React/Vue/Angular event handlers.

@jobo/sdk/testing — Test Runner

import { TestRunner } from "@jobo/sdk/testing";
import type { TestConfig, TestResult } from "@jobo/sdk/testing";

const runner = new TestRunner({
  providerDir: "./dist", // Path to compiled provider
  testUrl: "https://my-ats.com/job/apply", // Live application page
  headless: false, // Show browser for debugging
  browser: "chromium", // chromium | firefox | webkit
  timeout: 30000, // Page operation timeout
  slowMo: 0, // Slow motion delay
  testData: {
    // Values to fill in
    email: "[email protected]",
    "first name": "John",
    "last name": "Doe",
    phone: "+1234567890",
  },
});

const result = await runner.run();

// Result shape:
// {
//   success: boolean,
//   canHandle: boolean,
//   isExpired: boolean,
//   isCompleted: boolean,
//   fields: FormFieldInfo[],
//   setResults: Array<{ fieldId, handlerType, result }>,
//   errors: string[],
//   duration: number,
// }

@jobo/sdk/build — Compiler

import { compileProvider, watchProvider } from "@jobo/sdk";
import type { BuildConfig, BuildResult } from "@jobo/sdk";

const result = await compileProvider({
  projectDir: ".", // Provider project root
  outDir: "./dist", // Output directory
  minify: false, // Minify output
  sourcemap: true, // Generate source maps
  bundle: false, // Bundle into single file
});

// Watch mode
const watcher = await watchProvider(config, (result) => {
  console.log(result.success ? "Build OK" : "Build failed");
});
watcher.stop(); // Stop watching

BaseHandler — Protected Helpers

When extending BaseHandler, you have access to these protected methods:

| Method | Description | | --------------------------------------------- | --------------------------------------------------------------------- | | tagContainer(element) | Assign a unique data-field-id to a container element and return it. | | tagElement(element) | Assign a unique data-field-id to an individual element. | | findContainer(element) | Find the container element for an input using containerSelector. | | getQuestionText(container) | Get the label/question text from a container using labelSelector. | | isVisible(element) | Check if an element is visible. | | findContainerByFieldId(fieldId) | Find a container by its data-field-id attribute. | | findElementByFieldId(fieldId, selector) | Find an element with both data-field-id and a CSS selector. | | setReactValue(element, value) | React-compatible value setter. | | simulateTyping(element, text, options?) | Simulate typing with full event sequence. | | reactClick(element) | React-compatible click. | | delay(ms) | Simple delay. | | findBestOption(options, answer, threshold?) | Fuzzy match an answer to available options. | | waitForElement(selector, timeout?) | Wait for an element to appear in the DOM. | | isRequired(element) | Check if a field is required (required attr or aria-required). |


Provider Lifecycle

The C# orchestrator drives providers through this lifecycle:

1. Initialize
   → Navigate to the apply URL
   → Load shared helpers, handler scripts, and provider script via Playwright
   → Call initializeForAutoApply() if defined

2. Detection
   → canHandle()   — Can this provider handle the current page?
   → isExpired()   — Is the job posting expired?
   → getRedirectUrl() — Should we redirect to the apply form?

3. Form Loop (repeats for multi-step forms):
   a. getFields()      — All handlers discover their fields
   b. LLM decides      — Maps fields to candidate profile answers
   c. setField()       — Fill each field via the correct handler
   d. hasErrors()      — Check for validation errors
   e. detectNextStep() — Is there a next step or submit?
   f. submitForm()     — Click submit/next when ready

4. Completion
   → isCompleted() — Verify submission success

Multi-Step Forms

Many ATS platforms use multi-step forms. The orchestrator handles this automatically:

  1. After filling all fields on a step, it calls detectNextStep()
  2. If next_pending, it clicks the next button and waits for the page to settle
  3. It then calls getFields() again to discover the new step's fields
  4. This repeats until submit_pending is returned

Your provider just needs to correctly detect whether a submit or next button is available.


Field Type Reference

| Type | Description | Handler Responsibility | | -------------- | -------------------------- | -------------------------------------------------- | | text | Single-line text input | Set value with setReactValue or simulateTyping | | textarea | Multi-line text area | Same as text, but target <textarea> | | email | Email input | Same as text, target input[type="email"] | | select | Dropdown <select> | Match answer to option text, set .value | | multi-select | Multi-select dropdown | Select multiple options | | radio | Radio button group | Click the matching option's label/input | | checkbox | Checkbox (single or group) | Check/uncheck based on value | | typeahead | Autocomplete combobox | Type to trigger dropdown, select from results | | file | File upload | Return requiresPlaywright: true | | date | Date picker | Set value in the expected format | | number | Number input | Set numeric value | | toggle | Toggle switch | Click to toggle on/off | | hidden | Hidden input | Usually skip | | static | Read-only display | Skip |


CLI Reference

jobo init <name>

Scaffold a new provider project.

npx @jobo/sdk init greenhouse
# Creates jobo-provider-greenhouse/ with full project structure

Options:

  • -d, --dir <directory> — Output directory (default: current directory)

jobo build [dir]

Compile a provider to deployable JavaScript using esbuild.

npx jobo build                    # Build current directory
npx jobo build ./my-provider      # Build specific directory
npx jobo build --bundle           # Bundle into single file
npx jobo build --minify           # Minify for production
npx jobo build --watch            # Watch mode
npx jobo build --no-sourcemap     # Disable source maps
npx jobo build -o ./output        # Custom output directory

jobo test [dir]

Test a provider against a live application page.

npx jobo test --url https://jobs.ashbyhq.com/company/job/application
npx jobo test --url https://example.com/apply --headless
npx jobo test --url https://example.com/apply --browser firefox
npx jobo test --url https://example.com/apply --slow-mo 500
npx jobo test --url https://example.com/apply --data '{"email":"[email protected]"}'

Options:

  • -u, --url <url>(required) URL of a job application page
  • --headless — Run without showing the browser
  • --browser <browser>chromium (default), firefox, or webkit
  • --timeout <ms> — Timeout in ms (default: 30000)
  • --slow-mo <ms> — Slow motion delay (default: 0)
  • --data <json> — Test data as JSON string (overrides test.config.json)

jobo publish [dir]

Publish a provider to the Jobo registry.

npx jobo publish                              # Publish to default API
npx jobo publish --dry-run                    # Validate only
npx jobo publish --skip-build                 # Publish existing dist/ without rebuilding
npx jobo publish --no-bundle-build            # Auto-build in per-file mode instead of bundle mode
npx jobo publish --api-url https://api.jobo.world  # Custom API URL

Options:

  • --dry-run — Validate without publishing
  • --skip-build — Skip pre-publish build and use existing dist/
  • --no-bundle-build — Build in per-file mode before publish (default is bundled pre-build)
  • --api-url <url> — Jobo API base URL (default: http://localhost:5000, or JOBO_API_URL env var)

Testing Your Provider

test.config.json

Create a test.config.json in your project root with test data:

{
  "testUrl": "https://jobs.ashbyhq.com/example-company/job-id/application",
  "headless": false,
  "testData": {
    "email": "[email protected]",
    "first name": "John",
    "last name": "Doe",
    "phone": "+1234567890",
    "linkedin": "https://linkedin.com/in/johndoe",
    "resume": "path/to/resume.pdf",
    "location": "San Francisco, CA",
    "text": "Default text for unmatched fields"
  }
}

Test data keys are matched against field questions using substring matching. For example, a field with question "What is your email address?" will match the key "email".

Manual Testing Workflow

# 1. Build your provider
npx jobo build

# 2. Test field discovery (see what fields are found)
npx jobo test --url https://example.com/apply

# 3. Test with data (fill in fields)
npx jobo test --url https://example.com/apply --data '{"email":"[email protected]"}'

# 4. Test in headless mode (CI)
npx jobo test --url https://example.com/apply --headless

Best Practices

Selector Stability

  • Do use semantic selectors: input[type="email"], button[type="submit"], [role="combobox"]
  • Do use name attributes: input[name="email"], select[name="country"]
  • Do use ARIA attributes: [aria-label="Email"], [aria-required="true"]
  • Don't use CSS module hashes: .css-1a2b3c, ._fieldEntry_abc123
  • Don't use positional selectors: div > div:nth-child(3) > input
  • Don't use CSS-in-JS generated classes: [class*="styled-"]

Error Handling

  • Always check if elements exist before interacting with them
  • Return descriptive error messages in SetFieldResult.error
  • Use try/catch around DOM operations that might throw
  • Log warnings with console.warn('[YourATS]', message) for debugging

Performance

  • Use data-field-id to avoid re-processing fields
  • Check container.getAttribute('data-field-id') before tagging
  • Keep delay() calls minimal — only add them when the ATS needs time to react

React Compatibility

Most modern ATS platforms use React. Key tips:

  • Use setReactValue() instead of directly setting .value
  • Use reactClick() instead of .click() for buttons
  • Dispatch input and change events after value changes
  • For typeaheads, type character-by-character (paste: false) to trigger React's onChange

Example: Complete Ashby Provider

See examples/jobo-provider-ashby/ for a production-ready provider with 15 field handlers covering:

| Handler | Fields | | --------------- | -------------------------------------------- | | email | Email input (_systemfield_email) | | phone | Phone input | | text | Generic single-line text | | textarea | Multi-line text | | radio | Radio button groups | | checkbox | Single and grouped checkboxes | | yesno | Yes/No toggle switches | | typeahead | Autocomplete combobox fields | | location | Location typeahead (_systemfield_location) | | resume | File upload (delegates to Playwright) | | date | Date inputs | | number | Number inputs | | gender | EEOC gender select | | race | EEOC race/ethnicity select | | veteranStatus | EEOC veteran status select |


Publishing & Registry

When you run jobo publish, the CLI:

  1. Builds your provider automatically before publish (bundled by default)
  2. Validates your manifest.json (required fields, URL patterns)
  3. Collects all compiled .js files from dist/
  4. POSTs to the Jobo API at /api/autoapply/providers/publish
  5. The API stores the provider in MongoDB (autoapply_providers collection)
  6. On next server restart (or hot-reload), the provider is loaded into memory
  7. The orchestrator matches incoming URLs against your urlPatterns and uses your provider

Environment Variables

| Variable | Description | | -------------- | -------------------------------------------------- | | JOBO_API_URL | Base URL for the Jobo API (used by jobo publish) |


Requirements

  • Node.js 18+ (uses ES2022 features)
  • TypeScript 5.3+ (for development)
  • Playwright (installed automatically, used for testing)

Links

License

MIT — see LICENSE.