From skillenai
Invoke as `/skillenai:job-finder` (when installed via `/plugin install skillenai`).
How this skill is triggered — by the user, by Claude, or both
Slash command
/skillenai:job-finderThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Invoke as `/skillenai:job-finder` (when installed via `/plugin install skillenai`).
Invoke as /skillenai:job-finder (when installed via /plugin install skillenai).
This skill helps users find relevant job postings by parsing their resume, building a weighted skill profile, and running multi-signal searches against the Skillenai job index.
Every API call in this skill goes through the shared wrapper at ${CLAUDE_PLUGIN_ROOT}/scripts/api.py, which resolves the API key in its own process. The key is never visible to the agent's shell, never in curl argv, and never in the conversation transcript.
Before running any flow, verify credentials exist:
[ -n "$API_KEY" ] || [ -f ~/.skillenai/.env ]
If neither is set, stop and tell the user:
No Skillenai API key found. Run
/skillenai:api setupto authorize — it'll open a browser, you sign in or create an account, and the key gets saved automatically. Takes about 30 seconds.
All curl/API examples below use the wrapper:
WRAP="${CLAUDE_PLUGIN_ROOT}/scripts/api.py"
python "$WRAP" POST /v1/jobs/search '{"query": "...", "size": 20}'
See the security rules in the /skillenai:api skill — same rules apply here (never cat ~/.skillenai/.env, never echo $API_KEY, never curl -v, etc.).
The job finder operates in three phases:
This phase is fully agentic — the LLM reads the resume and extracts structured data. No API calls needed.
The user provides a resume as a file path (PDF, markdown, or plain text). Read it with the Read tool.
Parse the resume and extract:
{
"skills": [
{"name": "Python", "emphasis": 10, "evidence": "Listed in skills, used across 4 roles"},
{"name": "PyTorch", "emphasis": 7, "evidence": "Listed in skills, used in 2 roles"}
],
"job_titles": [
{"title": "Senior Manager, AI Engineering", "company": "Example Corp", "years": "2025-present"},
{"title": "Principal NLP Data Scientist", "company": "Example Corp", "years": "2023-2025"}
],
"target_titles": ["AI Engineer", "ML Engineer", "NLP Engineer", "Data Scientist"],
"seniority": "senior",
"years_experience": 10,
"location": {"country": "US", "region": "CA", "city": "San Francisco"},
"work_model_preference": "remote",
"education": ["MBA", "BS Physics"],
"domain_keywords": ["NLP", "LLM", "agents", "RAG", "search", "information retrieval"]
}
Score each skill based on how prominently it appears:
| Score | Criteria |
|---|---|
| 9-10 | Core identity skill — listed in skills section AND used across 3+ roles AND central to job descriptions |
| 7-8 | Strong skill — listed in skills AND used in 2+ roles |
| 5-6 | Moderate — listed in skills OR mentioned in 2+ roles |
| 3-4 | Light — mentioned once in a job description or listed but not emphasized |
| 1-2 | Peripheral — mentioned in passing, listed as a minor tool |
Seniority uses the LLM-extracted seniorityLevel field on job documents. It follows a dual-track ladder that diverges after "senior" into IC and management tracks. Equivalent levels across tracks share the same ordinal rank.
Common: intern → entry → mid → senior
↙ ↘
IC track: staff → principal
Mgmt track: lead/manager → director → vp → c-level
Classify the user's seniority from their most recent title + years of experience, using the same taxonomy:
| Pattern | Level | Track |
|---|---|---|
intern, internship, co-op | intern | common |
entry-level, associate, new grad, junior | entry | common |
(no seniority markers, 2-5 years) | mid | common |
senior, sr, 5-10 years | senior | common |
staff | staff | IC |
principal, distinguished | principal | IC |
lead, tech lead, team lead | lead | mgmt |
manager, engineering manager | manager | mgmt |
director, senior director | director | mgmt |
vp, vice president | vp | mgmt |
cto, ceo, chief, head of, executive | c-level | mgmt |
Important: Use these exact values when passing seniority to the search endpoint — they must match the LLM taxonomy.
After extracting the profile, ask the user for:
locationCountry filter (ISO code, e.g. "US") to restrict results to the user's countrySave the profile to reports/job-search-profile.json (or a path the user specifies) and present it to the user for confirmation.
The job search endpoint boosts jobs by skill entity IDs. Resolve the user's skill names to entity IDs in a single call:
WRAP="${CLAUDE_PLUGIN_ROOT}/scripts/api.py"
# Resolve all skills at once (max 50 per request)
python "$WRAP" POST /v1/resolution/entities \
'{
"names": [
{"name": "Python", "entity_type": "skill"},
{"name": "PyTorch", "entity_type": "skill"},
{"name": "LangChain", "entity_type": "skill"},
{"name": "RAG", "entity_type": "skill"}
],
"mode": "auto",
"limit": 1
}'
Response:
{
"results": [
{"name": "Python", "entity_type": "skill", "candidates": [
{"entity_id": "175c2b707caa6eb1", "canonical_name": "Python", "match_score": 1.0, "match_method": "exact"}
]},
{"name": "PyTorch", "entity_type": "skill", "candidates": [
{"entity_id": "f0109bbf0cbac010", "canonical_name": "PyTorch", "match_score": 1.0, "match_method": "exact"}
]}
]
}
Modes: auto tries exact lookup first, falls back to full-text search for misses. Use exact for speed when names are likely canonical, fts for fuzzy matching.
Build skill_boosts from the resolved IDs, weighting by resume emphasis:
[
{"entity_id": "175c2b707caa6eb1", "weight": 10.0},
{"entity_id": "f0109bbf0cbac010", "weight": 7.0}
]
Skills that fail to resolve (no candidates) should be omitted from skill_boosts — they'll still contribute via BM25 text matching in the query string.
Call the skills-by-role endpoint to compare against market demand. Role names are resolved via entity resolution (fuzzy OK), and comma-separated aliases are merged into a single profile:
WRAP="${CLAUDE_PLUGIN_ROOT}/scripts/api.py"
# Single role — entity-resolved (fuzzy matching)
python "$WRAP" GET "/v1/analytics/skills-by-role?role=Data+Scientist"
# Merge aliases into one profile
python "$WRAP" GET "/v1/analytics/skills-by-role?role=ML+Engineer,Machine+Learning+Engineer"
The response includes queried_role (original input), resolved_roles (canonical names matched), and a merged skill profile with job counts.
For each target role, compute:
coverage = sum(demand_weight_i * emphasis_weight_i) / sum(demand_weight_i)
Where:
demand_weight_i = job_count for skill i / max job_count in that roleemphasis_weight_i = resume emphasis (1-10) / 10, or 0 if not on resumePresent to the user:
Compose a natural-language summary for the hybrid search query:
"Senior AI/ML engineer. NLP, LLMs, RAG, AI agents, fine-tuning LoRA PEFT,
production ML systems. Python, PyTorch, LangChain, LangGraph, OpenSearch,
vector databases, embeddings, evaluation frameworks."
This text drives both the BM25 keyword matching and the server-side embedding for vector similarity. Emphasize the highest-weighted skills.
From the parsed job_titles and target_titles, build the title_boosts parameter. The weighting strategy depends on whether the user is continuing their career trajectory or pivoting:
Career pivot (target_titles differ significantly from job history):
target_titles in title_boosts, each at weight 10.0skill_boosts as the primary matching signal (skills transfer across roles)Career continuation (target_titles similar to job history):
target_titles at weight 10.0Example (continuation):
"title_boosts": [
{"title": "AI Engineering Manager", "weight": 10.0},
{"title": "ML Engineer", "weight": 10.0},
{"title": "Senior Manager, AI Engineering", "weight": 5.0},
{"title": "Principal NLP Data Scientist", "weight": 2.5},
{"title": "Senior NLP Data Scientist", "weight": 1.25}
]
Join domain_keywords from the profile into a text_boosts entry against ["extractedText"] with weight 2.0. This catches keyword co-occurrence patterns the constructed query and skill entity boosts might miss:
"text_boosts": [
{"text": "NLP LLM agents RAG search information retrieval vector search embeddings", "fields": ["extractedText"], "weight": 2.0}
]
The hybrid search endpoint handles all ranking in a single call — BM25 + vector via RRF, plus skill boosts, seniority scoring, recency decay, and filters:
WRAP="${CLAUDE_PLUGIN_ROOT}/scripts/api.py"
python "$WRAP" POST /v1/jobs/search \
'{
"query": "<profile summary from step 2d>",
"size": 30,
"k": 50,
"skill_boosts": [
{"entity_id": "abc123", "weight": 10.0},
{"entity_id": "def456", "weight": 7.0}
],
"title_boosts": [
{"title": "AI Engineer", "weight": 10.0},
{"title": "Senior Manager, AI Engineering", "weight": 5.0}
],
"text_boosts": [
{"text": "NLP LLM agents RAG embeddings vector search", "fields": ["extractedText"], "weight": 2.0}
],
"seniority": "senior",
"location_country": "US",
"recency_decay": "30d",
"min_salary": 180000,
"filters": {
"workModel": "remote"
}
}'
| Signal | How it works |
|---|---|
| BM25 + Vector (RRF) | Base relevance — text match and semantic similarity combined via Reciprocal Rank Fusion |
| Skill entity boosts | Nested query: jobs requiring a user's skill get boosted by that skill's emphasis weight |
| Title boosts | match_phrase queries on title field with slop 1. Each title phrase boosted by its weight. |
| Text boosts | multi_match queries on specified fields (default: extractedText). For injecting resume text or domain keywords. |
| Seniority (ordinal, dual-track) | Uses seniorityLevel (LLM-extracted). Exact match → boost 5; cross-track equivalent (e.g. staff ↔ lead) → boost 4; ±1 rank → boost 2; ±2+ → filtered out |
| Recency decay | Exponential decay on postedAt (falls back to ingestedAt when missing) — 30d scale means 30-day-old jobs score ~50% of new ones |
| Salary | Jobs with salaryMax >= min_salary get boosted. Missing salary data is neutral (no penalty). |
| Location country | location_country parameter filters by ISO code on locationCountry field. Uses match query to work with both text and keyword mappings. |
Pass in the filters object. Scalar values become term filters, lists become terms (OR), and dict values are passed as-is to the underlying search engine (enabling match, bool, match_phrase, etc.):
// Remote only
{"workModel": "remote"}
// Multiple work models
{"workModel": ["remote", "hybrid"]}
// Specific company
{"company": "Acme"}
Use the dedicated location_country parameter (not filters) for country-level filtering:
"location_country": "US"
"location_country": ["US", "CA"]
This uses a match query on locationCountry that works regardless of whether the field is mapped as text or keyword. Remote jobs without explicit country in their location text may be excluded.
Notes:
location_country is separate from filters — don't duplicate it in bothlocation/location_radius request fields apply geo-distance hard filters + proximity decay. Use only for hybrid/onsite searches (remote jobs often lack geocodes)geo_distance in the filter dict passthrough — it fails in the RRF pipeline context{
"hits": [
{
"id": "abc123...",
"score": 0.0085,
"source": {
"title": "Senior AI Engineer",
"company": "Acme",
"location": "Remote, US",
"seniority": "senior",
"workModel": "remote",
"sourceUrl": "https://...",
"salary": "$180,000-$220,000",
"topics": ["agents", "nlp"],
"enrichedAt": "2026-04-01T...",
"extractedText": "...",
"canonicalEntityIds": ["eid1", "eid2", "..."]
}
}
],
"total": 150,
"took_ms": 320
}
After getting initial results, review them against the user's resume and assess relevance using your own judgment. Think like a recruiter: do these results match the user's career goals, seniority, and core strengths?
If results feel off — wrong industry, wrong seniority band, missing the user's core strengths, irrelevant companies — reason about why and adjust search parameters accordingly:
Iterate as many times as needed until results are satisfactory or you determine the index simply doesn't have better matches. Each iteration should note what was changed and why.
For each returned job, the agentic layer should:
canonicalEntityIds against the user's resolved skill entity IDs to identify exact skill matches and gapsAfter presenting initial results, offer:
canonicalEntityIds across top results, resolve to names, compare to profilek, size, seniority range, or recency decayWRAP="${CLAUDE_PLUGIN_ROOT}/scripts/api.py"
python "$WRAP" POST /v1/query/search \
'{
"query": {
"size": 0,
"query": {"bool": {"filter": [{"match": {"title": "<target role>"}}]}},
"aggs": {
"skill_entities": {
"nested": {"path": "entities"},
"aggs": {
"skills_only": {
"filter": {"term": {"entities.resolved.entityType": "skill"}},
"aggs": {
"top_skills": {
"terms": {"field": "entities.resolved.canonicalName.keyword", "size": 50}
}
}
}
}
}
}
},
"indices": ["prod-enriched-jobs"]
}'
The prod-enriched-jobs index exposes these fields:
| Field | Type | Description |
|---|---|---|
documentId | keyword | MD5 hash of source URL |
title | text | Job title |
extractedText | text | Full job description |
company | keyword | Company name |
location | text | Location string |
locationCountry | keyword | ISO country code (from geocoded location entity) |
locationCity | keyword | City name (from geocoded location entity) |
locationGeocode | geo_point | Lat/lon coordinates (for distance scoring) |
salary | text | Raw salary text |
salaryMin | integer | Parsed minimum salary (USD) |
salaryMax | integer | Parsed maximum salary (USD) |
seniority | keyword | Scraper title-based classification (less reliable) |
seniorityLevel | keyword | LLM-extracted: intern, entry, mid, senior, staff, principal, lead, manager, director, vp, c-level |
postedAt | date | Job posting date |
roles | keyword[] | Structured role categories (e.g. Data Scientist, ML Engineer) |
workModel | keyword | remote, hybrid, onsite |
department | keyword | Department name |
sourceUrl | keyword | Original job posting URL |
topics | keyword[] | Coarse taxonomy tags |
entities | nested | NER-extracted entities with resolution (entityId, entityType, canonicalName) |
canonicalEntityIds | keyword[] | Flat list of resolved entity IDs (for quick matching) |
embedding | knn_vector | Dense embedding for semantic search |
enrichedAt | date | When the job was enriched |
| Endpoint | Method | Purpose |
|---|---|---|
/v1/jobs/search | POST | Multi-signal job search (RRF hybrid + skill/title/text boosts + seniority + location_country + recency) |
/v1/resolution/entities | POST | Resolve skill names → entity IDs (exact + FTS fallback) |
/v1/analytics/skills-by-role | GET | Skill distributions by role (entity-resolved, comma-separated aliases merged) |
/v1/query/search | POST | Raw search DSL (for aggregations, skill gap analysis) |
/v1/query/graph | POST | Cypher queries (for custom graph traversal) |
locationCountry coverage is partial — many remote jobs without an explicit country in the location text will be missing from country-filtered results.workModel coverage is partial — roughly half of jobs have an empty workModel and won't match workModel filters.location + location_radius is available, but many remote jobs lack locationGeocode, so geo-distance filtering may exclude valid remote roles. Use location/location_radius only for hybrid/onsite searches.salaryMin/salaryMax but coverage is sparse — many job boards don't provide salary data. The min_salary boost works when data is present but won't filter out jobs without salary info.seniorityLevel field, which is not populated for every job.The scripts/job_search.py helper (shipped with this plugin) wraps a subset of the hybrid search flow for one-shot searches from the CLI:
python "${CLAUDE_PLUGIN_ROOT}/scripts/job_search.py" \
"AI engineer NLP LLMs" --seniority senior --min-salary 180000 --remote
For the full resume → profile → refined search workflow described above, use the phased flow in this SKILL directly rather than the helper.
Write the final report to reports/job-search-<date>.md with:
After writing the report, open it in the user's preferred markdown viewer so they can review it immediately. Try editors in this order, using the first one on $PATH:
REPORT="reports/job-search-$(date +%Y-%m-%d).md"
if command -v cursor >/dev/null 2>&1; then
cursor "$REPORT"
elif command -v code >/dev/null 2>&1; then
code "$REPORT"
elif [ -n "$EDITOR" ]; then
"$EDITOR" "$REPORT"
else
open "$REPORT" 2>/dev/null || xdg-open "$REPORT" 2>/dev/null || echo "Report written to $REPORT"
fi
If the user has mentioned a preferred tool earlier in the conversation (e.g. VS Code, Cursor, Obsidian, glow, bat), use that instead of the fallback chain. If nothing opens successfully, print the path so they can open it themselves.
Provides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.
npx claudepluginhub skillenai/skillenai-api-skill --plugin skillenai