circles-and-sources

star 1

Strategy map for what to search WHERE, derived entirely from the candidate profile. The 5 concentric circles (work_mode + relocation) tell you the geographic scope; the 4 source tiers (LinkedIn → ATS aggregators → niche → web) tell you which platforms to drain in order. A scout that searches the wrong tier in the wrong circle wastes its quota and its `scout-coord` partition. Open this skill at boot (after `scout-coord`) and again whenever a circle is exhausted or a `[FEEDBACK]` from the Analyst suggests changing source.

leopu00 By leopu00 schedule Updated 6/6/2026

name: circles-and-sources description: Strategy map for what to search WHERE, derived entirely from the candidate profile. The 5 concentric circles (work_mode + relocation) tell you the geographic scope; the 4 source tiers (LinkedIn → ATS aggregators → niche → web) tell you which platforms to drain in order. A scout that searches the wrong tier in the wrong circle wastes its quota and its scout-coord partition. Open this skill at boot (after scout-coord) and again whenever a circle is exhausted or a [FEEDBACK] from the Analyst suggests changing source. allowed-tools: Bash(curl *), Bash(python3 /app/shared/skills/linkedin_check.py *)

circles-and-sources — read the profile, build the map

Two orthogonal axes:

  • Circles = WHERE (geographic / work-mode scope)
  • Tiers = WHICH platforms (in priority order)

Both come from $JHT_HOME/profile/candidate_profile.yml. Don't assume: read preferences.work_mode, location, preferences.relocation, then build the circles on top of what the candidate actually wants.

The 5 concentric circles

Exhaust each circle inside-out before moving outward.

# Circle What it is When to enter
1 🎯 Primary preference The mode + geography the candidate declared as priority. Always start here. Drain it first.
2 🗺️ Geo neighbours Areas immediately extensible from circle 1. Only if relocation allows OR circle 1 is exhausted.
3 ✈️ Targeted relocation Cities / countries listed in preferences.relocation (or inferred from "ovunque" / "Europa"). Only if relocation is non-empty (true / list / "ovunque").
4 🛰️ Satellite Geography outside the core target, lower probability. Only if circles 1-3 are exhausted.
5 🌗 Frontier Roles adjacent to the candidate's primary stack (sub-domains of same language, cross-functional, automation, ML adjacent, etc.). The candidate is treated as adaptable; the Scorer applies the gap penalty downstream. Only after circles 1-4 are drained for the day.

How to materialise circle 1 from the profile

preferences:
  work_mode: <remoto|ibrido|in sede|flessibile>
  ...
location: <city/area>
preferences:
  relocation: <true|false|"per la giusta posizione"|list>
work_mode Circle 1 = WHAT to search
remote Remote roles compatible with the candidate's timezone / country (e.g. Remote (EU only) for EU-based)
on-site Roles in location (city base) only
hybrid Roles in location city, hybrid-tagged or commute radius
flessibile Union of the three above — exhaust in order remote → city → hybrid

Circle 2 — geo neighbours

Circle 1 type Circle 2 expansion
Remote (national) Remote regional / continental compatible with candidate's timezone + work-auth
On-site Region / metropolitan area of the base country
Hybrid Same as on-site (commute radius widening)

Circle 3 — targeted relocation

Only if preferences.relocation is non-empty:

relocation value Circle 3 expansion
List (["Berlin", "Lisbon"]) Just those cities
"ovunque" Global hubs for the candidate's domain (finance → London, NYC, Zurich, Frankfurt, Singapore, Dublin, Luxembourg; tech → SF, Berlin, Amsterdam, Lisbon, Tel Aviv…). Rotate across them round-robin — do NOT drain the densest hub (e.g. London for finance) first, or the shortlist ends up hub-dominated (see Anti-bias rule, location guard).
"Europa" EU tech hubs (Berlin, London, Amsterdam, Lisbon, Dublin, Madrid, Paris, Stockholm, ...)
"per la giusta posizione" Skip circle 3, mark borderline candidates from circle 4 with relocation flag in notes

The 4 source tiers

Drain a tier completely before moving to the next.

Tier Type Sources Notes
1 LinkedIn linkedin_check.py (authenticated profile), curl with browser UA Universal: covers remote, on-site, hybrid. Mandatory first step for every circle. NEVER fetch MCP — blocked by robots.txt.
2 ATS aggregators Greenhouse boards, Lever boards, Indeed, Wellfound (ex AngelList) Work for any work_mode. Cover many companies in one scrape.
3 Niche boards (profile-specific) Pick by work_mode AND domain (see table below)
4 WebSearch + career pages WebSearch queries + scrape of company career pages Last resort only after tier 1-3 are drained.

Tier 3 — choose by work_mode + domain

Candidate's work_mode Niche boards to consider
remote Remote.co, WeWorkRemotely, RemoteOK, EURemoteJobs (or regional equivalents)
on-site / hybrid Local / national boards (InfoJobs, Glassdoor regional, Stepstone, Welcome to the Jungle FR, ...)
flessibile Combine remote + local
Domain-specific (any) Stack-specific niche: PyJobs (Python), GoJobs (Go), Djinni (Eastern Europe / dev), 4dayweek.io (4-day-week), ...

⚠️ Don't bring remote-specific boards into a non-remote search, and vice versa. WeWorkRemotely on a candidate who wants on-site Milan is wasted scraping.

Anti-bias rule (mandatory) — on company AND location

Two independent guards, both at end of batch:

  1. Company: if > 30% of a single batch's positions come from one company, switch source/query for the next batch. One scaleup dumping 12 roles on a board floods the pool — diversity matters more than volume.
  2. Location (city/area): if > 40% of a single batch comes from one city, the next batch MUST target a different circle-city. Without this, a candidate open to a multi-city circle (e.g. relocation "ovunque"/"Europa") gets a pool dominated by the single hub that has the most postings for their domain — finance → London, tech → SF/Berlin. Real incident (beta tester #2): a finance candidate received an almost London-only shortlist because London out-posts every other hub by ~10×. Rotate across the circle's cities round-robin; don't drain the densest hub first.
# pseudocode for the check at end of batch
from collections import Counter
batch = [...]
n = len(batch)

# guard 1 — company
top_company, c_count = Counter(p.company for p in batch).most_common(1)[0]
if c_count / n > 0.30:
    log(f"anti-bias company: {top_company} = {c_count}/{n} >30% → switch source/query")

# guard 2 — location (city), CUMULATIVO sull'intero run (NON solo questo batch)
# Il guard per-batch non basta: un hub (London per la finanza) resta sotto-soglia
# in ogni singolo batch eppure accumula il 60% del DB nel tempo (visto live sul
# beta: London=57/97=59%). Misura sul TOTALE del DB.
db_by_city = dict(db.execute(
    "SELECT COALESCE(loc_city, TRIM(SUBSTR(location,1,INSTR(location||',',',')-1))), COUNT(*) "
    "FROM positions GROUP BY 1"))
db_total = sum(db_by_city.values()) or 1
top_city, top_n = max(db_by_city.items(), key=lambda kv: kv[1])
if top_n / db_total > 0.35:                       # SOFT cap: nessuna città > ~35% del run
    log(f"anti-bias location CUMULATIVO: {top_city}={top_n}/{db_total} (>35%) → "
        f"STOP queries su {top_city}, prossimo sweep su città prioritarie sotto-servite")

Regola di bilanciamento geografico (cumulativa, soft-cap) — incentiva lo spread, non impone la parità:

  1. Leggi il profilo: le priority cities (campo location / preferences.relocation) sono il target. È normale e giusto che le città con più fit pesino di più — NON forzare uno split uniforme.
  2. Misura sul run intero prima di ogni nuovo sweep: SELECT loc_city, COUNT(*) FROM positions GROUP BY loc_city ORDER BY 2 DESC.
  3. Soft-cap ~35%: se UNA sola città supera il ~35% del totale DB, smetti di interrogarla per i prossimi sweep e ridirigi lo sforzo. Un hub (es. London per la finanza out-posta ogni altra città ~10×): lasciarlo correre produce uno shortlist hub-dominated, inutile per chi ha priorità multi-città.
  4. Quota di copertura priorità: le priority-city del profilo a 0 o sotto-servite hanno precedenza nei prossimi sweep — dedica query mirate (<provider>:<keyword>:<city>) finché non hanno una presenza minima, prima di tornare sugli hub già pieni.
  5. Città fuori-profilo come hub = doppio allarme: se la città dominante NON è tra le priority del profilo, è hub-bias + off-target → ribilancia con urgenza.

⚠️ Work-authorization come filtro PRIMA del bilanciamento (Brexit, visti)

Bilanciare le location non serve se le offerte non sono lavorabili dall'utente. Prima di accettare un hub, verifica la compatibilità di work-permit col profilo (cittadinanza / visti dichiarati):

  • 🇬🇧 UK post-Brexit: un cittadino UE senza visto UK NON può lavorare a Londra/UK senza sponsorship (Skilled Worker visa). Quindi per un profilo solo-UE le offerte UK valgono solo se il JD menziona esplicitamente visa sponsorship; altrimenti sono work-auth incompatibili → SKIP (vedi "Permissive filters", regola geo).
  • 🇨🇭 Svizzera / non-UE: stessa logica — verifica permesso di lavoro.
  • Regola pratica: se l'hub dominante è in un paese che richiede un permesso che l'utente non ha (e i JD non offrono sponsorship), quel volume è fantasma — non conta come copertura e va escluso dal pool, non solo bilanciato.

🗣️ Language-aware sourcing — non raccogliere ciò che verrà escluso per lingua

Stesso principio della work-auth, sul fronte linguistico. Se le lingue dell'utente (languages, con livello) NON coprono la lingua di lavoro locale di una città target, i ruoli che la richiedono saranno scartati a valle dall'Analista ([LANGUAGE]) — raccoglierli è spreco. Caso reale (beta): candidato con inglese C1 + tedesco solo conversazionale + niente IT/ES/FR → su 18 escluse, 11 erano per lingua locale obbligatoria (M&A in tedesco a Monaco/Zurigo, IB in italiano a Milano, ecc.).

Regola: prima di interrogare una città il cui idioma locale l'utente non padroneggia a livello business, biasa le query verso ruoli English-first / international:

  • Aggiungi qualificatori alla query: "English-speaking", "international team", "English required", nome di multinazionali/firm globali (Big4, bulge-bracket, scale-up internazionali) che lavorano in inglese anche in mercati non-anglofoni.
  • Per i ruoli che invece richiedono la lingua locale (e l'utente non l'ha a livello business): trattali come i UK-no-sponsor — non inserirli, oppure inseriscili solo se il JD dice esplicitamente che la lingua locale non è richiesta.
  • Inglese come lingua di lavoro ≠ paese anglofono: a Amsterdam, Zurigo, Lussemburgo, Lisbona molti ruoli finance girano in inglese. Sono il sweet spot per chi parla solo inglese ma vuole l'Europa continentale.

Esito: il pool che sopravvive all'Analista è più piccolo ma ad alto rendimento (accessibile per lingua E per work-auth), invece di gonfiarsi di ruoli che verranno scartati.

Permissive filters at SCOUT level

The Scout pre-filters only the totally out-of-scope cases. Do not do the Analyst's job — the candidate is treated as adaptable to adjacent roles. Skip a posting only if:

  • 🚫 Title contains explicitly: senior, lead, staff, principal, head of, director → SKIP (seniority gap too wide)
  • 🚫 Geographic work-auth incompatible with the profile (e.g. US-only / Canada-only and the candidate doesn't have visa) → SKIP
  • 🚫 Domain completely outside IT/coding (e.g. pastry chef, accountant, sales) when the candidate is in IT → SKIP
  • 🚫 Hard requirement of > real_years + 3 years of experience → SKIP (moderate gap is fine, the Scorer decides)

Everything else: insert it. Adjacent stacks (data, devops, platform, frontend, automation, ML adjacent, etc.) all go through; the Scorer assigns a fit-proportional score and the user sees them.

Listening to Analyst feedback

When the Analyst sends [FEEDBACK] with a recurring tag ([SENIORITY] · [STACK] · [GEO] · [LINGUA]):

  1. ACK the message
  2. Adjust the next batch's queries / sources per the suggestion
  3. Prioritise the suggested alternative source/filter for the next rotation
  4. Notify the Capitano only if a systemic bias emerges (not solvable by source change)

Example: Analyst says "4 of last 5 from greenhouse.io require senior+, switch source". Next batch you skip greenhouse.io, try a Lever board or a niche junior-friendly source.

Anti-patterns

  • ❌ Searching circle 2 before exhausting circle 1 — wastes scope, dilutes results.
  • ❌ Going to tier 4 (WebSearch) before tier 1-3 are drained — WebSearch is the noisiest source, save it for last.
  • ❌ Inferring relocation = "ovunque" for a candidate whose profile says false — read the profile, don't project.
  • ❌ Using LinkedIn via fetch MCP — blocked by robots.txt; always linkedin_check.py (authenticated) or curl with browser UA.
  • ❌ Including senior-titled JDs hoping the Scorer will filter them — wastes Scorer budget, adds noise. The 4 SCOUT-level filters above are the right place.
  • ❌ Anti-bias check forgotten — one greedy company swamps your batch.

See also

  • scout-coord — boot-time partition between scouts (HOW to split this map across instances).
  • position-insert — what to do for each candidate position once you've decided WHERE to look.
  • agents/scout/scout.md — the Scout's orchestrator prompt that calls this skill.
  • agents/_team/architettura.md Phase 1 — bigger picture of Discovery within the pipeline.
Install via CLI
npx skills add https://github.com/leopu00/job-hunter-team --skill circles-and-sources
Repository Details
star Stars 1
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator