Home

/

Build It in a Weekend. Run It for Years.

/

The shape of the loop

The shape of the loop

Chapter 5
Part II
3
min read

The shape of the loop

This is a real Semantic Kernel agent, not a chatbot in a trench coat. The difference is that the model doesn't just answer. It's given tools (functions it can call) and a goal, and it decides which tools to call, in what order, until the goal is met. Thought, action, observation, repeat. That's the ReAct pattern, and Semantic Kernel runs it for you when you switch on automatic function calling.

We expose three functions to the model: fetch the job, fetch the CV, and save the verdict. (Snippets here are illustrative excerpts: the shape, not a copy-paste product.)

public sealed class ScreeningPlugin(IAtsClient ats, ILlmGateway gateway)
{
    [KernelFunction, Description("Fetch a job's title and requirements by id.")]
    public Task<JobBrief> GetJob(string jobId) => ats.GetJobAsync(jobId);

    [KernelFunction, Description("Fetch a candidate's parsed CV text by id.")]
    public Task<CvText> GetCandidateCv(string candidateId) =>
        ats.GetCandidateCvAsync(candidateId);

    [KernelFunction, Description("Persist the screening verdict back to the ATS.")]
    public Task SaveVerdict(string candidateId, string jobId, ScreeningResult result) =>
        ats.WriteScreeningNoteAsync(candidateId, jobId, result);
}

Each [KernelFunction] is a tool the model can choose to invoke. The Description text isn't decoration. It's how the model knows what the tool is for. Notice what's missing: nowhere does this plugin call a model SDK. Every LLM call in the whole system goes through one guarded ILlmGateway.

Wiring the ATS tools — both stacks

The IAtsClient behind those functions is the only thing that changes between Bullhorn and JobAdder. The agent above doesn't know or care which one it's talking to.

Bullhorn. A job is a JobOrder; a CV comes from the candidate's file attachments, or (cleaner) from Bullhorn's own resume parser, which hands back the CV body as plain text.

async Task<JobBrief> GetJobAsync(string id)
{
    // restUrl + BhRestToken come from the login flow in Ch.4
    var url = $"{restUrl}entity/JobOrder/{id}" +
              "?fields=id,title,status,employmentType,clientCorporation";
    var job = await _http.GetFromJsonAsync<BhEntity<JobOrder>>(
                  $"{url}&BhRestToken={token}");
    return JobBrief.From(job.Data); // title + requirement text
}

For the CV text, Bullhorn's resume/parseToCandidate endpoint with &populateDescription=text returns the resume body already flattened to plain text. No separate "convert to text" step exists, and you don't want to be parsing PDFs by hand.

JobAdder. A job is a Job; every call carries a bearer token, and you prefix the path with the per-account api base URL that came back with the token, not a hardcoded host.

async Task<JobBrief> GetJobAsync(string id)
{
    // apiBase came back in the OAuth token response (e.g. https://api.jobadder.com/v2)
    var req = new HttpRequestMessage(HttpMethod.Get, $"{apiBase}/jobs/{id}");
    req.Headers.Authorization =
        new AuthenticationHeaderValue("Bearer", accessToken); // expires ~60 min; refresh
    var job = await _http.SendAsync(req);
    var data = await job.Content.ReadFromJsonAsync<JaJob>();
    // requirements live in skillTags.tags (JobOrderSkillTags: matchAll + tags[])
    return JobBrief.From(data); // title + skillTags.tags
}

JobAdder's OAuth token response hands back the access token (~60 minutes), a rotating refresh token, and a per-account api base URL you store alongside them and prefix onto every call. A job's requirements aren't a free-text blob. They sit in skillTags.tags. The API is the official, public OpenAPI v2 spec, so none of this needs a [verify]. Bullhorn leads because it's the flagship; JobAdder is the second rail, same agent, different client.

The screening prompt — asking for reasons, not vibes

The model's instructions do the heavy lifting. We don't ask "is this a good candidate?", because that invites a confident guess. We ask it to work requirement by requirement, cite the CV, and return structured output we can store and audit.

var instructions = """
  You screen ONE candidate against ONE job. You do not hire or reject.
  1. Call GetJob, then GetCandidateCv.
  2. For each job requirement, decide Met / Partially met / Not met,
     and quote the CV line that justifies it. No quote => Not met.
  3. Score 0-100 for fit, and set recommendation:
     Shortlist | Reject | Flag-for-human (use Flag when evidence is thin
     or anything looks discriminatory or off).
  4. Call SaveVerdict with your reasoning attached.
  Never infer gender, age, ethnicity or nationality. Never reward or
  penalise a candidate on those grounds. If a requirement is unlawful
  to screen on, flag it for a human and move on.
  """;

That last paragraph is the Amazon lesson turned into a guardrail. The model is told, in writing, not to screen on protected characteristics, and when it can't decide cleanly, the correct move is Flag-for-human, not a guess. The leash is built into the instructions, not bolted on after.

the-math-no-recruiter-can-win-by-hand
what-an-ai-agent-actually-is
the-leash
the-toolkit
the-model-small-capable-swappable
talking-to-your-ats
use-case-1-resume-screening-against-a-job
the-shape-of-the-loop
running-it-thought-action-observation
use-case-2-cv-formatting-redacting-for-clients
reformatting-into-your-branded-template
resume-shortlisting
that-was-easy
security-compliance
keeping-pii-out-of-the-llm
exceptions-reliability
silent-api-drift-the-ats-changes-under-you
when-it-fails-anyway-dead-letter-and-the-leash
monitoring-observability
maintenance-the-lifecycle
the-scorecard-success-metrics-kpis
build-vs-buy-vs-managed
what-an-engineer-actually-costs
what-the-wider-data-says-happens-next
conclusion-how-this-gets-run-for-you
the-promises-behind-the-service
fuller-code-listings
one-full-screening-react-loop-semantic-kernel
env-deployment-reference
secrets-in-dev-vs-production
bullhorn-jobadder-endpoint-cheat-sheets
sources-further-reading
compliance-primary-law-sources

Download the full PDF for free?

Download full PDF
build-it-in-a-weekend.pdf
Oops! Something went wrong while submitting the form.
Related Chapters