Exa
Back to demo

How It Works

A job description in, a ranked and fact-checked shortlist out. Below are the exact Exa API calls; copy them to build your own pipeline.

1

Turn the JD into a search plan

An LLM reads the pasted job description and produces a small plan: the role, must-have and nice-to-have requirements, 4-6 rubric dimensions to grade every candidate on, and 4-6 talent segments, the non-overlapping pools (competitors, adjacent vendors, consultancies) where equivalent people work today.

2

Create one Exa Agent discovery run per segment

Each segment becomes its own Exa Agent run, so several narrow pools get searched instead of one broad query. The outputSchema forces every candidate into the same graded and verified shape, including the inline factCheck verdict and the dated mobility work-history signals behind the likely-to-move score, dataSources blends Fiber.ai's B2B people database into the search via Exa Connect, and input.exclusion skips people you already have.

Discovery run (one per segment)
const discoverySchema = {
  type: "object",
  additionalProperties: false,
  required: ["candidates"],
  properties: {
    candidates: {
      type: "array",
      maxItems: 10,
      items: {
        type: "object",
        additionalProperties: false,
        required: ["name", "currentTitle", "currentCompany", "location", "linkedinUrl", "cloudArchitecture", "preSales", "factCheck", "overallFit", "mobility"],
        properties: {
          name: { type: "string" },
          currentTitle: { type: "string" },
          currentCompany: { type: "string" },
          location: { type: ["string", "null"] },
          linkedinUrl: { type: ["string", "null"] },
          cloudArchitecture: {
            type: "object",
            additionalProperties: false,
            required: ["level", "signals"],
            properties: {
              level: { type: "string", enum: ["strong", "partial", "none", "unknown"] },
              signals: { type: "array", items: { type: "string" } },
            },
          },
          preSales: {
            type: "object",
            additionalProperties: false,
            required: ["level", "signals"],
            properties: {
              level: { type: "string", enum: ["strong", "partial", "none", "unknown"] },
              signals: { type: "array", items: { type: "string" } },
            },
          },
          factCheck: {
            type: "object",
            additionalProperties: false,
            required: ["exists", "linkedinValid", "matchesRole", "verifiedTitleCompany"],
            properties: {
              exists: { type: "string", enum: ["confirmed", "likely", "uncertain", "not_found"] },
              linkedinValid: { type: "string", enum: ["valid", "unverifiable", "wrong"] },
              matchesRole: { type: "string", enum: ["strong", "partial", "weak", "no"] },
              verifiedTitleCompany: { type: "string" },
            },
          },
          overallFit: {
            type: "object",
            additionalProperties: false,
            required: ["tier", "confidence", "concerns"],
            properties: {
              tier: { type: "string", enum: ["exceptional", "strong", "moderate", "weak", "unknown"] },
              confidence: { type: "string", enum: ["low", "medium", "high"] },
              concerns: { type: "array", items: { type: "string" } },
            },
          },
          mobility: {
            type: "object",
            additionalProperties: false,
            required: ["monthsInCurrentRole", "monthsAtCurrentCompany", "avgMonthsPerPriorRole", "seniorityVsRole", "signals"],
            properties: {
              monthsInCurrentRole: { type: ["number", "null"] },
              monthsAtCurrentCompany: { type: ["number", "null"] },
              avgMonthsPerPriorRole: { type: ["number", "null"] },
              seniorityVsRole: { type: "string", enum: ["step_up", "aligned", "step_down", "unknown"] },
              signals: { type: "array", items: { type: "string" } },
            },
          },
        },
      },
    },
  },
};

const run = await exa.agent.runs.create({
  query: "Find real candidates for: Senior Solutions Architect at a cloud provider, a customer-facing pre-sales role. MUST-HAVE: 7+ years of infrastructure experience; hands-on cloud architecture; pre-sales or customer-facing delivery. FOCUS for this search (verify independently, do NOT treat as ground truth): solutions architects and sales engineers at Google Cloud, Microsoft Azure, and Oracle Cloud. Strongly prioritize people based in or near: San Francisco Bay Area. A Fiber.ai B2B people data source is attached; use it to surface candidates and confirm each person's current title, employer, location, and LinkedIn URL. Grade every rubric dimension with a level plus the concrete public signals that justify it. For each candidate, also fill factCheck: verify the person exists, the LinkedIn URL plausibly belongs to them, and their background matches the role. For each candidate, also fill mobility from their dated public work history: monthsInCurrentRole, monthsAtCurrentCompany, avgMonthsPerPriorRole, and seniorityVsRole (step_up, aligned, or step_down versus their current level; step_down means overqualified), with the dated evidence in mobility.signals. Grade strictly and comparatively: a dimension is strong only with direct public evidence; reserve tier exceptional for at most one or two near-perfect fits. Use null or unknown when a fact is not publicly supported; NEVER fabricate a name, LinkedIn URL, employer, tenure, or number. Return up to 10 real candidates.",
  effort: "medium",
  dataSources: [{ provider: "fiber_ai" }],
  input: { exclusion: [{ person: "Alex Rivera" }] },
  outputSchema: discoverySchema,
});

// run.id → poll it in step 3

Add one { "level", "signals" } property per rubric dimension from your plan; cloudArchitecture and preSales above are examples.

3

Poll each run until it completes

Runs take a couple of minutes. Poll with the run id from step 2 and read the graded candidates out of output.structured. Completed runs also return output.grounding: source citations keyed to output fields like structured.candidates[3]. The demo attributes each entry to its candidate by index and shows the citations as clickable sources in the expanded row, so the rubric claims can be checked against where they came from.

Poll a run
const finished = await exa.agent.runs.pollUntilFinished(run.id, {
  pollInterval: 8000,
});

// finished.status                        → completed | failed | cancelled
// finished.output.structured.candidates  → the graded candidates
// finished.output.grounding              → citations per output field, e.g.
//   { field: "structured.candidates[3]",
//     citations: [{ url, title }], confidence: "medium" }
4

Dedupe and score

Merge all segments, dedupe by normalized LinkedIn slug (name as a fallback), drop anyone at the excluded employer, and compute two independent scores per candidate. The match score rolls the graded dimensions plus the overallFit tier into one number, shown as a percentage of the rubric's maximum. The likely-to-move score converts the mobility signals into a 0-100 propensity to switch jobs: 18-42 months in the current role raises it, a just-started role or an overqualified candidate (seniorityVsRole step_down) lowers it, and habitually short prior stints raise it. Candidates with no public work-history dates show "no signal" instead of a fake neutral number, and every scored candidate keeps the inputs behind the score (tenure, prior-role cadence, seniority vs the role, dated evidence), surfaced in the expanded row and the CSV export so the number is never unexplained. Ranking uses the match score only; likely-to-move is a second triage axis. This is plain application code, no API calls.

5

Rank with inline verification

The discovery run returns a factCheck object for each candidate alongside the rubric grades. Application code joins that verdict to the candidate key, re-ranks confirmed and likely matches first, and sinks anyone marked not_found, no match, or currently at the excluded employer.

6

Find more with previousRunId

To grow the list, start a new run per segment with previousRunId pointing at that segment's last run, so the agent keeps its research context instead of starting cold, and pass every already-seen name in input.exclusion. Reuse the same outputSchema from step 2. This same exclusion mechanism dedupes against any list you already have. The results export to CSV straight from the table.

Find more (continuation run)
const more = await exa.agent.runs.create({
  query: "Find up to 10 MORE candidates matching the same brief, excluding everyone already returned and everyone in the exclusion list. Apply the same rubric grading, inline factCheck, mobility (dated work-history) fields, and anti-fabrication rules as before.",
  effort: "medium",
  previousRunId: run.id,
  input: { exclusion: [{ person: "Jane Doe" }, { person: "Alex Rivera" }] },
  outputSchema: discoverySchema,
});