@jobo-ai/autoapply-sdk
v1.0.0
Published
AutoApply SDK for building Jobo providers — like Apify actors for job application automation
Maintainers
Readme
@jobo/sdk
TypeScript SDK for building Jobo AutoApply providers — like Apify actors, but for job application automation.
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:
- Discovers form fields on a page (text inputs, dropdowns, radios, file uploads, etc.)
- Fills each field with the correct value from a candidate's profile
- 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 | publishHow 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 installThis 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 testing3. 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 build4. Test against a live page
npx jobo test --url https://jobs.ashbyhq.com/company/job-id/applicationThis 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 registryjobo 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:
- Discover all fields of that type on the page (
discoverFields) - 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 withinsertFromPasteeventpaste: false— Types character by character withtypingDelayms between keystrokesblur: true(default) — Blurs the element after typing
findBestOption(options, answer, threshold?) — Multi-strategy fuzzy matching:
- Exact match (case-insensitive)
- Answer is substring of option
- Option is substring of answer
- All words from answer appear in option
- Normalized substring match
- 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
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 watchingBaseHandler — 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 successMulti-Step Forms
Many ATS platforms use multi-step forms. The orchestrator handles this automatically:
- After filling all fields on a step, it calls
detectNextStep() - If
next_pending, it clicks the next button and waits for the page to settle - It then calls
getFields()again to discover the new step's fields - This repeats until
submit_pendingis 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 structureOptions:
-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 directoryjobo 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, orwebkit--timeout <ms>— Timeout in ms (default: 30000)--slow-mo <ms>— Slow motion delay (default: 0)--data <json>— Test data as JSON string (overridestest.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 URLOptions:
--dry-run— Validate without publishing--skip-build— Skip pre-publish build and use existingdist/--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, orJOBO_API_URLenv 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 --headlessBest Practices
Selector Stability
- Do use semantic selectors:
input[type="email"],button[type="submit"],[role="combobox"] - Do use
nameattributes: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-idto 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
inputandchangeevents 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:
- Builds your provider automatically before publish (bundled by default)
- Validates your
manifest.json(required fields, URL patterns) - Collects all compiled
.jsfiles fromdist/ - POSTs to the Jobo API at
/api/autoapply/providers/publish - The API stores the provider in MongoDB (
autoapply_providerscollection) - On next server restart (or hot-reload), the provider is loaded into memory
- The orchestrator matches incoming URLs against your
urlPatternsand 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
- API Docs — enterprise.jobo.world/docs
- GitHub — github.com/Prakkie91/jobo-sdk
- npm — npmjs.com/package/@jobo/sdk
- Node.js Client — npmjs.com/package/jobo-enterprise
License
MIT — see LICENSE.
