career-ops
Guides

Scan Job Portals

Set up and run the portal scanner to find new job openings automatically.

This guide walks through setting up portals.yml and running the portal scanner to find new job openings automatically.

Before you start

Make sure you have completed the Quick Start guide. You need cv.md and modes/_profile.md in place before scanning is useful.

Set up portals.yml

portals.yml is the file that controls what the scanner looks for and where it looks. You create it once from the included template, then edit it to match your target roles.

Run this command from the project folder:

Terminal
cp templates/portals.example.yml portals.yml

Open portals.yml in any text editor. The steps below walk through the three sections you edit first: the title filter, the companies to track, and the search queries.

Configure your title filter

The title filter is how the scanner decides if a job is worth showing you. It checks the job title against two lists of keywords.

Find the title_filter section near the top of the file:

portals.yml
title_filter:
  positive:
    - "AI"
    - "ML"
    - "Product Manager"
  negative:
    - "Junior"
    - "Intern"
  seniority_boost:
    - "Senior"
    - "Staff"
    - "Lead"

Replace the positive list with keywords that match your target roles. A job must match at least one keyword from this list to pass through. The check is not case-sensitive, so "AI" also catches "ai engineer".

Replace the negative list with keywords that rule a job out. If any of these appear in the title, the job is skipped — even if it matched a positive keyword.

Leave seniority_boost as is unless you want to add or remove seniority levels. These keywords do not filter anything out — they just move matching jobs higher in the results.

Example: If you are looking for Rails engineering roles, your filter might look like this:

portals.yml
title_filter:
  positive:
    - "Rails"
    - "Ruby"
    - "Full Stack"
    - "Backend"
  negative:
    - "Junior"
    - "Intern"
    - "PHP"
    - "Java"
  seniority_boost:
    - "Senior"
    - "Staff"
    - "Lead"

Tip

Start with a small positive list of three to five keywords. You can always add more later. A long list makes it harder to see why a job passed or failed.

Add companies to track

The tracked_companies section is a list of specific companies you want the scanner to check every time. The scanner goes directly to each company's job page and reads the open roles.

Find the tracked_companies section near the bottom of the file. Each entry looks like this:

portals.yml
tracked_companies:
  - name: Anthropic
    careers_url: https://job-boards.greenhouse.io/anthropic
    api: https://boards-api.greenhouse.io/v1/boards/anthropic/jobs
    enabled: true

  - name: OpenAI
    careers_url: https://openai.com/careers
    enabled: true

To add a company, copy an existing entry and change the values:

FieldWhat to put
nameThe company name as you want it to appear in results
careers_urlThe direct URL to the company's job listings page
apiOptional. The Greenhouse API URL if the company uses Greenhouse. Leave it out if you are not sure.
enabledSet to true to include the company or false to skip it without deleting the entry.

To find a company's careers_url, go to the company's website, click Careers or Jobs, and copy the URL of the page that lists open roles. That is the URL to use.

To disable a company without deleting it, set enabled: false:

portals.yml
  - name: Stripe
    careers_url: https://stripe.com/jobs/search
    enabled: false

Note

Every company in tracked_companies must have a careers_url. Without it, the scanner has no page to visit and will skip that entry.

Review the search queries

The search_queries section runs broader web searches to find roles at companies not in your tracked list. The template includes many pre-built queries.

You do not need to change this section to get started. The pre-built queries cover major job boards including Greenhouse, Ashby, among others.

When you are ready to customize, each query looks like this:

portals.yml
search_queries:
  - name: Greenhouse — Rails Engineer
    query: 'site:job-boards.greenhouse.io "Rails Engineer" OR "Ruby on Rails" remote'
    enabled: true

To turn off a query you do not need, set enabled: false. To add a new one, copy an existing entry, give it a new name, and update the query text.

Choose the language of postings (optional)

Some job boards publish the same posting in several languages. If you apply in only some of them, list those languages in order of preference:

portals.yml
preferred_languages: [en]

When a posting has versions in those languages, the scan keeps the one in the language that comes first in your list; otherwise it keeps the posting's default version. It is an order of preference, not a filter: no posting is dropped, and you still get one row per posting. Regional variants count as one language, so en-GB is en. To set the language for one company only, add the board's own parameter to its careers_url: ?language=xx on Personio, ?lang=xx on Recruitee.

Run a scan

There are two ways to run a scan. Use the one that fits what you need.

Option A — Run the script directly

Terminal
npm run scan

Run this from the project folder. The script reads portals.yml, hits each company's API directly, and writes any new jobs to data/pipeline.md. It uses no AI tokens and runs in about 30 seconds.

This option works for any company whose job page runs on a platform the scanner has a built-in provider for: Greenhouse, Ashby, and Lever, plus dozens of other applicant tracking systems and job boards such as Workable, Workday, and SmartRecruiters. The full list is in SUPPORTED_JOB_BOARDS.md. Companies with no matching provider are skipped, and the scan reports how many.

Preview results before saving them:

Terminal
npm run scan -- --dry-run

This runs the full scan but does not write anything to disk. Use it to check your filter settings before committing results.

Scan a single company:

Terminal
npm run scan -- --company Anthropic

Replace Anthropic with any company name from your tracked_companies list. The match is not case-sensitive.

Scan Dayforce career sites:

Career sites on Dayforce (Ceridian), at jobs.dayforcehcm.com, block plain web requests, so npm run scan cannot read them. A separate script opens a real browser session for them instead, which makes it slower than the main scan. List the boards under dayforce_boards in portals.yml, one entry per company with its name and its tenant (the company's code in the Dayforce address); templates/portals.example.yml shows the format. Then run:

Terminal
npm run scan:dayforce

New jobs go to data/pipeline.md, like the main scan. Add -- --dry-run to preview without saving, -- --tenant <tenant> to scan a single board, or -- --debug to save a screenshot of each board to output/. If a board shows a bot challenge, the script skips it; it never tries to get past one.

Option B — Run the AI-powered scan inside Claude Code

Open Claude Code in the project folder and type:

Claude Code
/career-ops scan

This version visits each company's job page directly using a browser, so it works even for companies that do not have a public API. It also runs the search_queries from your portals.yml to find roles at companies not in your tracked list.

Use this option when:

  • A company in your list uses a platform the script has no provider for, so the script scan skips it
  • You want to discover new companies, not just check known ones
  • The script scan missed something you expected to see

If the script missed a posting you found yourself, ask it why first:

Terminal
node coverage-check.mjs <url> --company "<company>" --title "<job title>"

It reads your portals.yml and scan history without going online, says why the posting was missed (the company is not tracked, its entry is disabled, no provider reads its job site, or a title or location filter dropped it) and proposes the one change that would have caught it. It never edits portals.yml for you.

Tip

Option B uses AI model tokens and takes longer than the script. Whether those tokens are billed to an API key or count toward your subscription depends on how your CLI is set up (see the FAQ). For a quick daily check, Option A is faster.

Read the scan output

When the scan finishes, you will see a summary like this:

Terminal
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Portal Scan — 2026-04-13
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Companies scanned:     42
Total jobs found:      318
Filtered by title:     291 removed
Duplicates:            4 skipped
New offers added:      23

New offers:
  + Anthropic | AI Engineer | San Francisco, CA
  + ElevenLabs | Solutions Architect | Remote
  ...

→ Run /career-ops pipeline to evaluate new offers.

Here is what each line means:

LineWhat it tells you
Companies scannedHow many companies from tracked_companies were checked
Total jobs foundAll open roles seen across those companies, before any filtering
Filtered by titleJobs removed because the title did not match your title_filter
Duplicates skippedJobs already in your pipeline or tracker — not added again
New offers addedJobs that passed all filters and are new to you

If New offers added is 0, first look for this warning in the scan output:

Terminal
⚠️  1 target(s) returned zero jobs without a successful HTTP response: Acme

It means that company's portal never answered successfully, so the zero does not prove its board is empty. The company is recorded as unverified_zero in data/portal-health.tsv and counts toward its failure streak. Check that portal before you change your filters. A slow board can also run out of time before it answers: the scanner waits 10 seconds per request by default, and CAREER_OPS_HTTP_TIMEOUT_MS=30000 npm run scan gives it 30.

If there is no such warning, your title filter may be too strict. Try adding a keyword to your positive list and run the scan again.

Where new jobs go: Each new offer is added as a line in data/pipeline.md. That is the list of jobs waiting to be evaluated.

What data/scan-history.tsv does: Every URL the scanner sees — whether it was added, filtered, or skipped — gets logged here. This is how the scanner avoids showing you the same job twice across future scans. One exception is a posting removed by your location_filter or max_posting_age_days setting in portals.yml. It is logged as skipped_location or skipped_age, and it can show up again once you change that setting. You do not need to edit this file.

Evaluate new offers from a scan

After a scan adds new offers to data/pipeline.md — your list of pending job offers — run the pipeline command to evaluate each one against your profile. This step uses Claude to read the job description, compare it against your cv.md and modes/_profile.md, and produce a scored summary for each role.

Open Claude Code in the project folder and type:

Terminal
/career-ops pipeline

Claude reads every pending URL in data/pipeline.md, visits each job posting, and evaluates it. Results come back in batches. Each batch shows a table like this:

Batch 1 done (Anthropic, ElevenLabs, Mistral):

#CompanyRoleScoreLegitimacy
024AnthropicAI Engineer4.8/5High Confidence
025ElevenLabsSolutions Architect4.1/5High Confidence — fast-growing
026Mistral AIProduct Manager3.2/5Proceed w/ Caution — role closed

Here is what each column means:

ColumnWhat it tells you
#The report number. Use it to find the full report in reports/ — for example, report 024 is reports/024-anthropic-2026-04-13.md
ScoreHow well the role fits your profile, from 1 to 5
LegitimacyWhether the posting looks real and active. "High Confidence" means the job is live and the details check out. "Proceed w/ Caution" means something raised a flag — read the note next to it.

To read the full evaluation for a role, you have two options:

  • Open the report file directly — find it in the reports/ folder using the number from the table.
  • Use the dashboard — run ./dashboard/career-dashboard from the project folder to browse all your reports in a terminal interface. You can filter by score and status, and open any report without leaving the terminal.

What to do next

When /career-ops pipeline finishes, each job has a score from 1 to 5. You can find the full report for any role in the reports/ folder. The score shows how well the role matches your profile.

Use the score as your starting point:

ScoreRecommended action
4.5 or higherStrong match. Run /career-ops apply right away.
4.0 – 4.4Good fit. Run /career-ops apply. You can also run /career-ops contacto first to reach out to someone on the team before applying.
3.5 – 3.9Could go either way. Run /career-ops deep to learn more before you decide.
Below 3.5Skip it unless you have a specific reason to apply.

Here is what each command does:

  • /career-ops apply — Opens the application form and writes answers tailored to your profile. It stops before submitting so you can review everything first.
  • /career-ops contacto — Finds a hiring manager or team member on LinkedIn and drafts a short outreach message for you to send.
  • /career-ops deep — Researches the company and the role in depth. Use this when you are on the fence and want more information before deciding.
  • /career-ops tracker — Shows the status of every role in your pipeline. Run this any time you want to see where things stand.

Tip

You do not have to go in order. If a role scores 4.8 but you have never heard of the company, run /career-ops deep first. The commands work on their own — use which ever one fits your situation.

On this page