npm package: @jobo-ai/autoapply-sdk
Description: AutoApply SDK for building Jobo providers — like Apify actors for job application automation
Author: Jobo
Latest version: 1.0.0

<img src="https://raw.githubusercontent.com/Prakkie91/jobo-sdk/main/jobo-logo.png" alt="Jobo" width="120" />

# @jobo/sdk

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

[![npm](https://img.shields.io/npm/v/@jobo/sdk)](https://www.npmjs.com/package/@jobo/sdk)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.3+-blue)](https://www.typescriptlang.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

---

## 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

```bash
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

```bash
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

```bash
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

```bash
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:

```json
{
  "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:

```typescript
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:

```typescript
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

```typescript
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

```typescript
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

```typescript
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:

```typescript
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:

```typescript
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

```typescript
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.

```typescript
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 `mousedown` → `mouseup` → `click` with `bubbles: true`. Works better than `element.click()` with React/Vue/Angular event handlers.

### `@jobo/sdk/testing` — Test Runner

```typescript
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: "test@example.com",
    "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

```typescript
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.

```bash
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.

```bash
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.

```bash
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":"test@test.com"}'
```

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.

```bash
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:

```json
{
  "testUrl": "https://jobs.ashbyhq.com/example-company/job-id/application",
  "headless": false,
  "testData": {
    "email": "test@example.com",
    "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

```bash
# 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":"test@test.com"}'

# 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/`](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

- **Website** — [jobo.world/enterprise](https://jobo.world/enterprise/)
- **API Docs** — [enterprise.jobo.world/docs](https://enterprise.jobo.world/docs)
- **GitHub** — [github.com/Prakkie91/jobo-sdk](https://github.com/Prakkie91/jobo-sdk)
- **npm** — [npmjs.com/package/@jobo/sdk](https://www.npmjs.com/package/@jobo/sdk)
- **Node.js Client** — [npmjs.com/package/jobo-enterprise](https://www.npmjs.com/package/jobo-enterprise)

## License

MIT — see [LICENSE](LICENSE).