> ## Documentation Index
> Fetch the complete documentation index at: https://outcrawl.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Apply to the roles you picked, tailored and approved by you

> An agent reads each posting and drafts every answer from your resume; you edit and approve each draft; a second run submits exactly what you approved.

export const OutcrawlStyles = () => {
  if (typeof document !== 'undefined' && !document.getElementById('outcrawl-overrides')) {
    const s = document.createElement('style');
    s.id = 'outcrawl-overrides';
    s.textContent = `
      /* Match the product's radius grid (4 / 6 / 8 / 9999), not Mintlify's default. */
      .rounded-sm { border-radius: 0.375rem !important; } /* 6px, --r-ctl */
      .rounded-md { border-radius: 0.375rem !important; } /* 6px, --r-ctl */
      .rounded-lg { border-radius: 0.5rem !important; }   /* 8px, --r-panel */
      .rounded-l-sm { border-top-left-radius: 0.375rem !important; border-bottom-left-radius: 0.375rem !important; }
      .rounded-r-sm { border-top-right-radius: 0.375rem !important; border-bottom-right-radius: 0.375rem !important; }
      .rounded-l-md { border-top-left-radius: 0.375rem !important; border-bottom-left-radius: 0.375rem !important; }
      .rounded-r-md { border-top-right-radius: 0.375rem !important; border-bottom-right-radius: 0.375rem !important; }
      .rounded-l-lg { border-top-left-radius: 0.5rem !important; border-bottom-left-radius: 0.5rem !important; }
      .rounded-r-lg { border-top-right-radius: 0.5rem !important; border-bottom-right-radius: 0.5rem !important; }

      /* Cards: never pure white in light mode, matching the one-big-card surface tone. */
      .card { background-color: #fcfcfc !important; border-radius: 0.5rem !important; }
      html.dark .card { background-color: #141311 !important; }

      /* Docs hero box */
      .oc-hero { background-color: #fcfcfc; border: 1px solid #e5e2dc; }
      html.dark .oc-hero { background-color: #141311; border-color: #26241f; }
      html.dark .oc-hero h1 { color: #edeae4; }

      /* Thin, transparent-track, hide-until-hover scrollbar. */
      ::-webkit-scrollbar { width: 6px; height: 6px; background-color: transparent; }
      ::-webkit-scrollbar-track { background-color: transparent; }
      ::-webkit-scrollbar-thumb { background-color: rgba(155, 155, 155, 0.5); border-radius: 10px; transition: opacity 0.3s ease; opacity: 0; }
      ::-webkit-scrollbar-thumb:hover { background-color: rgba(155, 155, 155, 0.7); }
      *:hover::-webkit-scrollbar-thumb,
      *:focus::-webkit-scrollbar-thumb,
      *:active::-webkit-scrollbar-thumb { opacity: 1; }
      * { scrollbar-width: thin; scrollbar-color: rgba(155, 155, 155, 0.5) transparent; }
    `;
    document.head.appendChild(s);
  }
  return null;
};

<OutcrawlStyles />

A good application answers the questions this company asked, in words that fit this role, and that is why each one takes an hour. This program sends an agent to read each posting you picked and its application form, and to draft every answer from your resume. Each draft lands in a file. You read it, change anything you like, and approve it; only then does a second run complete the form with your approved answers, attach your resume and submit.

It is for a handful of roles you chose and read. It does not look for jobs, and it is not for sending one application to hundreds of postings.

## What you need

* An Outcrawl API key in `OUTCRAWL_API_KEY`.
* Your resume twice: `resume.md`, the text the agent tailors from, and `resume.pdf`, the file it attaches. A run can attach a file to a page but cannot read it, so the text copy is what it works from.
* The postings. Greenhouse, Lever and Ashby forms need no account, which is what this program expects.
* A terminal. The program asks you about each draft.

## How it works

<Steps>
  <Step title="Pass 1 reads the posting and drafts">
    An [agent run](/docs/agents/overview) reads the posting and its form without typing into it, and answers a [schema](/docs/agents/overview#typed-results): every question as the form asks it, whether it is required, a drafted answer, a cover letter if the form takes one, and the requirements your resume does not show. Two [rules](/docs/account/rules) are enforced: `Only use job-boards.greenhouse.io.` (each posting's own site) and `Never send, post or upload anything.`
  </Step>

  <Step title="You approve each draft">
    Every draft is saved as `applications/<posting>.json`. The program shows the role and the gaps, and waits while you edit the file. Type `submit` to approve it or press Enter to skip it. No run is waiting on you: the approval sits between two runs.
  </Step>

  <Step title="Pass 2 submits what you approved">
    A second run reads the file as you left it and completes the form with those answers, word for word. `resume.pdf` goes to the run as a [file](/docs/agents/files) straight after submit; it is destroyed when the run ends. The rule `Never create an account or sign up.` is enforced, so a form that wants an account comes back to you unsubmitted.
  </Step>

  <Step title="You get the confirmation">
    Each run answers whether it submitted, the confirmation the site showed, or what is missing, with a replay link to watch exactly what it did.
  </Step>
</Steps>

<Note>
  The approval is the split between the two runs, not a rule. A button labelled "Submit application" is not one Outcrawl's irreversible check recognises, so no rule could block it. The only run told to submit is the one that starts after you type `submit`.
</Note>

## The program

<CodeGroup>
  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
  import { stdin, stdout } from 'node:process';
  import { createInterface } from 'node:readline/promises';
  import { setTimeout as sleep } from 'node:timers/promises';
  import { ConcurrencyLimitError, Outcrawl, QuotaExceededError } from '@outcrawl/sdk';
  import type { AgentRequest } from '@outcrawl/sdk';

  const oc = new Outcrawl();

  // The roles you picked. A handful you would apply to by hand, not a blast.
  const postings = [
    'https://job-boards.greenhouse.io/figma/jobs/5498127004',
    'https://jobs.lever.co/plaid/6f2c1a9e-3b7d-4e0a-9c55-1d8e2b7f4a10',
    'https://jobs.ashbyhq.com/linear/2b9d4c1e-7a3f-4f6b-8e21-5c0d9a7b3e64',
  ];
  const me = {
    name: 'Ada Park',
    email: 'ada.park@example.com',
    phone: '+1 415 555 0142',
    location: 'San Francisco, CA',
    linkedin: 'https://www.linkedin.com/in/adapark',
  };
  const resumeText = await readFile('resume.md', 'utf8'); // what the agent reads to tailor
  const resumePdf = new Uint8Array(await readFile('resume.pdf')); // what it attaches to the form

  // Runs at once. Keep it below your plan's browser count (Free 2, Builder 5, Pro 25, Scale 100).
  const MAX_AT_ONCE = 2;

  interface Draft {
    company: string;
    role: string;
    fields: { label: string; required: boolean; answer: string }[];
    coverLetter?: string;
    gaps?: string[];
  }

  const draftSchema = {
    type: 'object',
    properties: {
      company: { type: 'string' },
      role: { type: 'string' },
      fields: {
        type: 'array',
        description: 'Every question on the application form, in order, except name, contact details and resume upload',
        items: {
          type: 'object',
          properties: {
            label: { type: 'string', description: 'The question exactly as the form asks it' },
            required: { type: 'boolean' },
            answer: { type: 'string', description: 'Your drafted answer' },
          },
          required: ['label', 'required', 'answer'],
        },
      },
      coverLetter: { type: 'string', description: 'Under 200 words, only if the form accepts one' },
      gaps: { type: 'array', items: { type: 'string' }, description: 'Requirements the resume does not show' },
    },
    required: ['company', 'role', 'fields'],
  };

  const submittedSchema = {
    type: 'object',
    properties: {
      submitted: { type: 'boolean' },
      confirmation: { type: 'string', description: 'The confirmation message the site showed' },
      leftForYou: { type: 'string', description: 'Why it was not submitted, and what is missing' },
    },
    required: ['submitted'],
  };

  // Submit a run and answer its id, waiting for a free browser slot instead of failing.
  async function start(request: AgentRequest): Promise<string> {
    for (;;) {
      try {
        return (await oc.agent(request).submitted).id;
      } catch (error) {
        if (error instanceof ConcurrencyLimitError) {
          await sleep(20_000); // no Retry-After: a slot frees when one of your runs ends
        } else if (error instanceof QuotaExceededError && error.ceiling === 'requests_per_minute') {
          await sleep(error.retryAfterSeconds * 1000); // the error says how long to wait
        } else {
          throw error; // out of monthly credits, or a bad request: retrying cannot help
        }
      }
    }
  }

  async function inPool<T, R>(items: readonly T[], size: number, work: (item: T) => Promise<R>): Promise<R[]> {
    const results: R[] = new Array<R>(items.length);
    let next = 0;
    const worker = async () => {
      while (next < items.length) {
        const index = next++;
        results[index] = await work(items[index] as T);
      }
    };
    await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
    return results;
  }

  // Pass 1: read each posting and its form, and draft. Nothing is typed into the form.
  await mkdir('applications', { recursive: true });
  const drafts = await inPool(postings, MAX_AT_ONCE, async (url) => {
    const run = await oc.agent.job(await start({
      task:
        `Open ${url}. Read the job posting and its application form. Do not type into the form or submit anything. ` +
        'Using my resume below, draft an answer to every question on the form, tailored to this role: plain, ' +
        'specific, and true to the resume, never claiming anything it does not support. List any requirement the ' +
        `resume does not show.\n\nMy resume:\n${resumeText}`,
      caps: { budget: '75.00', duration: '5m' },
      rules: [`Only use ${new URL(url).hostname}.`, 'Never send, post or upload anything.'],
      schema: draftSchema,
    })).settled();
    if (run.data === undefined) {
      console.log(`No draft for ${url}: run ended ${run.stopReason ?? run.status}, replay ${run.replayUrl ?? 'none'}`);
      return undefined;
    }
    const file = `applications/${new URL(url).pathname.split('/').filter(Boolean).join('-')}.json`;
    await writeFile(file, JSON.stringify({ url, ...(run.data as Draft) }, null, 2));
    return file;
  });

  // You read every draft, edit the file if you want, and approve it or skip it.
  const prompt = createInterface({ input: stdin, output: stdout });
  const approved: string[] = [];
  for (const file of drafts) {
    if (file === undefined) continue;
    const draft = JSON.parse(await readFile(file, 'utf8')) as Draft & { url: string };
    console.log(`\n${draft.role} at ${draft.company}: ${draft.fields.length} answers in ${file}`);
    if (draft.gaps?.length) console.log(`  The resume does not show: ${draft.gaps.join('; ')}`);
    const answer = await prompt.question('Edit the file if you like, then type "submit" to send it, or Enter to skip: ');
    if (answer.trim() === 'submit') approved.push(file);
  }
  prompt.close();

  // Pass 2: only what you approved, with the answers as they are in the file now.
  const outcomes = await inPool(approved, MAX_AT_ONCE, async (file) => {
    const draft = JSON.parse(await readFile(file, 'utf8')) as Draft & { url: string };
    const job = oc.agent.job(await start({
      task:
        `Open ${draft.url} and apply. Personal details: ${JSON.stringify(me)}. Attach the file resume.pdf where the ` +
        `form asks for a resume or CV. Answer the questions with exactly these answers, word for word: ` +
        `${JSON.stringify(draft.fields)}.${draft.coverLetter ? ` Cover letter, where the form accepts one: ${draft.coverLetter}` : ''} ` +
        'If the form asks a required question that is not answered here, or wants me to create an account, stop ' +
        'without submitting and say what is missing. Otherwise submit the application.',
      caps: { budget: '115.00', duration: '8m' },
      rules: [`Only use ${new URL(draft.url).hostname}.`, 'Never create an account or sign up.'],
      schema: submittedSchema,
    }));
    await job.addFile({ name: 'resume.pdf', bytes: resumePdf, contentType: 'application/pdf' });
    const run = await job.settled();
    return { role: `${draft.role} at ${draft.company}`, ...(run.data as object | undefined), replay: run.replayUrl };
  });

  console.table(outcomes);
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import asyncio
  import json
  from pathlib import Path
  from urllib.parse import urlparse

  from outcrawl import ConcurrencyLimitError, Outcrawl, QuotaExceededError

  # The roles you picked. A handful you would apply to by hand, not a blast.
  POSTINGS = [
      "https://job-boards.greenhouse.io/figma/jobs/5498127004",
      "https://jobs.lever.co/plaid/6f2c1a9e-3b7d-4e0a-9c55-1d8e2b7f4a10",
      "https://jobs.ashbyhq.com/linear/2b9d4c1e-7a3f-4f6b-8e21-5c0d9a7b3e64",
  ]
  ME = {
      "name": "Ada Park",
      "email": "ada.park@example.com",
      "phone": "+1 415 555 0142",
      "location": "San Francisco, CA",
      "linkedin": "https://www.linkedin.com/in/adapark",
  }
  RESUME_TEXT = Path("resume.md").read_text()  # what the agent reads to tailor
  RESUME_PDF = Path("resume.pdf").read_bytes()  # what it attaches to the form

  # Runs at once. Keep it below your plan's browser count (Free 2, Builder 5, Pro 25, Scale 100).
  MAX_AT_ONCE = 2

  DRAFT_SCHEMA = {
      "type": "object",
      "properties": {
          "company": {"type": "string"},
          "role": {"type": "string"},
          "fields": {
              "type": "array",
              "description": "Every question on the application form, in order, except name, contact details and resume upload",
              "items": {
                  "type": "object",
                  "properties": {
                      "label": {"type": "string", "description": "The question exactly as the form asks it"},
                      "required": {"type": "boolean"},
                      "answer": {"type": "string", "description": "Your drafted answer"},
                  },
                  "required": ["label", "required", "answer"],
              },
          },
          "coverLetter": {"type": "string", "description": "Under 200 words, only if the form accepts one"},
          "gaps": {"type": "array", "items": {"type": "string"}, "description": "Requirements the resume does not show"},
      },
      "required": ["company", "role", "fields"],
  }

  SUBMITTED_SCHEMA = {
      "type": "object",
      "properties": {
          "submitted": {"type": "boolean"},
          "confirmation": {"type": "string", "description": "The confirmation message the site showed"},
          "leftForYou": {"type": "string", "description": "Why it was not submitted, and what is missing"},
      },
      "required": ["submitted"],
  }


  async def start(oc: Outcrawl, **request) -> str:
      """Submit a run and answer its id, waiting for a free browser slot instead of failing."""
      while True:
          try:
              return (await oc.agent(**request).submitted)["id"]
          except ConcurrencyLimitError:
              await asyncio.sleep(20)  # no Retry-After: a slot frees when one of your runs ends
          except QuotaExceededError as error:
              if error.ceiling != "requests_per_minute":
                  raise  # out of monthly credits: retrying cannot help
              await asyncio.sleep(error.retry_after_seconds)  # the error says how long to wait


  async def main() -> None:
      Path("applications").mkdir(exist_ok=True)
      async with Outcrawl() as oc:
          slots = asyncio.Semaphore(MAX_AT_ONCE)

          # Pass 1: read each posting and its form, and draft. Nothing is typed into the form.
          async def draft(url: str) -> Path | None:
              async with slots:
                  run_id = await start(
                      oc,
                      task=(
                          f"Open {url}. Read the job posting and its application form. Do not type into the form or "
                          "submit anything. Using my resume below, draft an answer to every question on the form, "
                          "tailored to this role: plain, specific, and true to the resume, never claiming anything it "
                          f"does not support. List any requirement the resume does not show.\n\nMy resume:\n{RESUME_TEXT}"
                      ),
                      caps={"budget": "75.00", "duration": "5m"},
                      rules=[f"Only use {urlparse(url).hostname}.", "Never send, post or upload anything."],
                      schema=DRAFT_SCHEMA,
                  )
                  run = await oc.agent.job(run_id).settled()
              if not run.get("data"):
                  print(f"No draft for {url}: run ended {run.get('stopReason') or run['status']}, replay {run.get('replayUrl')}")
                  return None
              file = Path("applications") / ("-".join(part for part in urlparse(url).path.split("/") if part) + ".json")
              file.write_text(json.dumps({"url": url, **run["data"]}, indent=2))
              return file

          drafts = await asyncio.gather(*(draft(url) for url in POSTINGS))

          # You read every draft, edit the file if you want, and approve it or skip it.
          approved = []
          for file in drafts:
              if file is None:
                  continue
              content = json.loads(file.read_text())
              print(f"\n{content['role']} at {content['company']}: {len(content['fields'])} answers in {file}")
              if content.get("gaps"):
                  print(f"  The resume does not show: {'; '.join(content['gaps'])}")
              answer = await asyncio.to_thread(input, 'Edit the file if you like, then type "submit" to send it, or Enter to skip: ')
              if answer.strip() == "submit":
                  approved.append(file)

          # Pass 2: only what you approved, with the answers as they are in the file now.
          async def apply(file: Path) -> dict:
              content = json.loads(file.read_text())
              cover = f" Cover letter, where the form accepts one: {content['coverLetter']}" if content.get("coverLetter") else ""
              async with slots:
                  run_id = await start(
                      oc,
                      task=(
                          f"Open {content['url']} and apply. Personal details: {json.dumps(ME)}. Attach the file "
                          "resume.pdf where the form asks for a resume or CV. Answer the questions with exactly these "
                          f"answers, word for word: {json.dumps(content['fields'])}.{cover} If the form asks a required "
                          "question that is not answered here, or wants me to create an account, stop without "
                          "submitting and say what is missing. Otherwise submit the application."
                      ),
                      caps={"budget": "115.00", "duration": "8m"},
                      rules=[f"Only use {urlparse(content['url']).hostname}.", "Never create an account or sign up."],
                      schema=SUBMITTED_SCHEMA,
                  )
                  job = oc.agent.job(run_id)
                  await job.add_file(name="resume.pdf", content=RESUME_PDF, content_type="application/pdf")
                  run = await job.settled()
              return {"role": f"{content['role']} at {content['company']}", **(run.get("data") or {}), "replay": run.get("replayUrl")}

          for outcome in await asyncio.gather(*(apply(file) for file in approved)):
              print(outcome)


  asyncio.run(main())
  ```
</CodeGroup>

## What you get back

A draft, `applications/figma-jobs-5498127004.json`, before you edit it:

```json theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
{
  "url": "https://job-boards.greenhouse.io/figma/jobs/5498127004",
  "company": "Figma",
  "role": "Product Designer, Editor",
  "fields": [
    {
      "label": "Why are you interested in working at Figma?",
      "required": true,
      "answer": "I have spent four years designing editing tools for a collaborative whiteboard, most recently leading the redesign of its selection and alignment model, which cut layout-related support tickets by a third. Figma's editor is where that work matters most, and I want to work on it with the people who set the standard."
    },
    {
      "label": "Link to your portfolio",
      "required": true,
      "answer": "https://adapark.design"
    },
    {
      "label": "Will you now or in the future require visa sponsorship?",
      "required": true,
      "answer": "No"
    }
  ],
  "gaps": ["Shipping design systems at the scale of a multi-product company"]
}
```

After pass 2, one row per approved application:

```json theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
[
  {
    "role": "Product Designer, Editor at Figma",
    "submitted": true,
    "confirmation": "Thank you for applying. Your application has been received.",
    "replay": "https://outcrawl.ai/play/ses_7Kq2mD"
  },
  {
    "role": "Senior Product Designer at Linear",
    "submitted": false,
    "leftForYou": "The form asks for a salary expectation, which is required and was not in the approved answers. Nothing was submitted.",
    "replay": "https://outcrawl.ai/play/ses_9TmW4c"
  }
]
```

Add the missing answer to that file and run pass 2 for it again.

## What it costs

An agent run is billed for the model's usage, browser time and proxy usage, per [pricing](/docs/billing/pricing). A typical run costs about 15 credits a minute all in, proxy usage included. That is an estimate: the real figure depends on the task, and a page-heavy form costs more.

* **A draft** takes about 75 s: **about 19 credits**.
* **A submission** takes about 2.5 minutes: **about 38 credits**.

So **about 57 credits an application** you send, and 19 for one you skip. Each draft's `caps.budget` is 75 and each submission's is 115. On the Free plan a run takes one file of up to 2 MiB; Builder takes 5 files of up to 4 MiB.

## Make it yours

* Watch a submission as it happens: log the `watchUrl` from `job.submitted`, open it, and press take control in the live viewer to correct a field yourself before handing back. While you hold the wheel, the run bills browser time (1 credit a minute) and proxy usage only, with no model usage.
* Keep one resume per kind of role (`resume-design.md`, `resume-research.md`) and pick one per posting.
* Keep a log: append each outcome, with the date and replay link, to a CSV of everything you applied to.
* Ask pass 1 for a short note on each gap, so you can decide whether to apply at all before approving.

## Related

<CardGroup cols={2}>
  <Card title="Files" icon="paperclip" href="/docs/agents/files">Attach a file to a run.</Card>
  <Card title="Agents" icon="player-play" href="/docs/agents/overview">Runs, caps and typed results.</Card>
  <Card title="Rules" icon="shield-check" href="/docs/account/rules">Enforced rules and guidance.</Card>
  <Card title="Human in the loop" icon="hand-click" href="/docs/agents/human-in-the-loop">Take and release control of a live run.</Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.