Home

/

Build It in a Weekend. Run It for Years.

/

Fuller Code Listings

Fuller Code Listings

Appendix A
Appendix
4
min read

This appendix collects the longer-form versions of the code that runs an AI recruitment agent: the guarded ILlmGateway every model call routes through, and full Bullhorn and JobAdder clients covering auth, read and write. It is for the technically curious reader, or the engineer your supplier puts on the project. Where shorter snippets show the shape, these show a little more of the plumbing.

One rule applies to every listing below:

Illustrative, not a copy-paste product. Real type and method names, real Bullhorn and JobAdder endpoints and auth flows, real library APIs (Semantic Kernel, Polly v8, Serilog, OpenTelemetry) — but boilerplate is elided with // …, error handling is trimmed for readability, and nothing here has been run as a finished system. It compiles in the mind, not on a build server. Treat it as a faithful blueprint, not a release.

And the spine of the whole book holds throughout: every model call routes through the guarded ILlmGateway. Nothing in this appendix calls a model SDK directly. If a listing talks to the LLM, it goes through the gate — allowlist → DLP → fail-closed → call. That isn't a convention you can forget; it's enforced by the type system, as the guarded ILlmGateway listing below shows.

The Bullhorn and JobAdder details below are drawn from each vendor's public official docs and are high-confidence. JobAdder publishes an official OpenAPI v2 spec (interactive docs; developer portal), so its endpoints, headers and field names are verifiable — there are no [verify] flags on the JobAdder code. The auth flows for both are verified.

A.1 The guarded ILlmGateway

The one door. Allowlist enforced by the type system, a DLP inspection that fails closed, the model call, then an output guardrail on the response before anything is returned. This is the guardrail layer made concrete.

// The single chokepoint for every LLM call in the system.
// Illustrative excerpt — not a copy-paste product.
public interface ILlmGateway
{
    Task<LlmResult> SendAsync(AllowlistedRequest req, CancellationToken ct);
}

// The allowlist is enforced BY THE TYPE. There is no constructor or factory
// overload that accepts a raw document string — so a developer in a hurry
// physically cannot hand a whole CV to the model.
public sealed class AllowlistedRequest
{
    private AllowlistedRequest(IReadOnlyDictionary<string, string> fields)
        => Fields = fields;

    public IReadOnlyDictionary<string, string> Fields { get; }

    // Only structured, named fields can ever be constructed.
    public static AllowlistedRequest From(ScreeningFields f) => new(new Dictionary<string, string>
    {
        ["skills"]         = string.Join(", ", f.Skills),
        ["titles"]         = string.Join(", ", f.Titles),
        ["yearsExperience"]= f.YearsExperience.ToString(),
        ["qualifications"] = string.Join(", ", f.Qualifications),
    });
    // NOTE: there is deliberately no From(string rawCv) overload. The wrong path
    //       does not compile — see the commented "won't compile" line in Ch.9.3.

    internal StructuredPayload ToStructuredPayload() => StructuredPayload.From(Fields);
}

public sealed class GuardedLlmGateway(
    IDlpInspector dlp,
    IChatClient model,
    ISafeLogSink log) : ILlmGateway
{
    public async Task<LlmResult> SendAsync(AllowlistedRequest req, CancellationToken ct)
    {
        // 1. ALLOWLIST — req already carries only structured fields, never raw text.
        var payload = req.ToStructuredPayload();

        // 2. DLP INSPECT (input) — fail CLOSED on anything that isn't provably clean.
        var inScan = await dlp.InspectAsync(payload.AsText(), ct);
        if (inScan.Status != ScanStatus.Clean)
        {
            log.Write(SafeLogEvent.Blocked("inbound", inScan.FindingTypes)); // typed, no PII
            throw new GuardrailBlockedException(inScan.Findings);            // blocked, not logged-and-continued
        }

        // 3. CALL the model — only now, only with a payload we have cleared.
        var response = await model.CompleteAsync(payload, ct);

        // 4. OUTPUT GUARDRAIL — scan the response before it can be stored or sent onward.
        var outScan = await dlp.InspectAsync(response.Text, ct);
        if (outScan.Status != ScanStatus.Clean)
        {
            log.Write(SafeLogEvent.Blocked("outbound", outScan.FindingTypes));
            throw new GuardrailBlockedException(outScan.Findings);
        }

        log.Write(SafeLogEvent.Completed(payload.PromptVersion, response.TokensUsed));
        return new LlmResult(response.Text, response.TokensUsed);
    }
}

The DLP engine behind steps 2 and 4 is GCP Sensitive Data Protection / Cloud DLP (AWS Macie + Comprehend / Azure AI Language PII detection). The "fail closed" branch is the whole point: if the scanner errors, times out, or returns ambiguous findings, the call is blocked, not waved through. A guardrail that fails open is decoration.

One door. Guard it once, prove it once, and nothing else in the system can leak — because nothing else is allowed to call the model.

A.2 Bullhorn client: auth, read, write

Bullhorn's REST API is three-legged with a mandatory step zero (resolve the swimlane). The end state you care about is a BhRestToken + restUrl pair, which you reuse until a 401 tells you it has expired. The walkthrough of talking to your ATS covers the auth flow in full.

A.2.1 — Auth: step zero, then the three legs

// Illustrative excerpt — not a copy-paste product.
public sealed class BullhornAuth(HttpClient http)
{
    // STEP 0 — resolve the user's data centre ("swimlane"). No auth. NEVER hardcode hosts.
    public async Task<LoginInfo> ResolveSwimlaneAsync(string apiUsername) =>
        await http.GetFromJsonAsync<LoginInfo>(
            "https://rest.bullhornstaffing.com/rest-services/loginInfo" +
            $"?username={Uri.EscapeDataString(apiUsername)}")
        ?? throw new InvalidOperationException("loginInfo returned nothing");
    // -> { oauthUrl, restUrl }  (bases for this user's swimlane: west / east / emea / …)

    // LEG 1 — authorize, headless variant: returns a one-time auth code via the redirect.
    public async Task<string> AuthorizeAsync(LoginInfo info, BhCreds c)
    {
        var url = $"{info.OauthUrl}/authorize?client_id={c.ClientId}" +
                  $"&response_type=code&action=Login" +
                  $"&username={Uri.EscapeDataString(c.User)}" +
                  $"&password={Uri.EscapeDataString(c.Pass)}" +
                  $"&redirect_uri={Uri.EscapeDataString(c.RedirectUri)}&state={c.Csrf}";
        var resp = await http.GetAsync(url);                    // redirects to redirect_uri?code=…
        return ExtractCodeFromRedirect(resp);                   // pull ?code= from Location
    }

    // LEG 2 — exchange code for tokens. access_token lives 10 MINUTES. refresh_token rotates.
    public async Task<BhTokens> TokenAsync(LoginInfo info, string code, BhCreds c)
    {
        var resp = await http.PostAsync(
            $"{info.OauthUrl}/token?grant_type=authorization_code&code={code}" +
            $"&client_id={c.ClientId}&client_secret={c.ClientSecret}" +
            $"&redirect_uri={Uri.EscapeDataString(c.RedirectUri)}", null);
        return await resp.Content.ReadFromJsonAsync<BhTokens>();
        // -> { access_token, refresh_token, expires_in }
    }

    // LEG 3 — swap the access_token for a REST session. THIS is what every entity call uses.
    public async Task<BhSession> RestLoginAsync(LoginInfo info, string accessToken)
    {
        var resp = await http.PostAsync(
            $"{info.RestUrl}/login?version=2.0&access_token={accessToken}", null);
        return await resp.Content.ReadFromJsonAsync<BhSession>();
        // -> { BhRestToken, restUrl }
        // restUrl = https://rest{N}.bullhornstaffing.com/rest-services/{corpToken}/
        // corpToken is BAKED INTO restUrl — read it from here, never hardcode it.
    }

    // REFRESH — when the 10-minute access_token is dead. Refresh tokens are SINGLE-USE: persist the new one.
    public async Task<BhTokens> RefreshAsync(LoginInfo info, string refreshToken, BhCreds c)
    {
        var resp = await http.PostAsync(
            $"{info.OauthUrl}/token?grant_type=refresh_token&refresh_token={refreshToken}" +
            $"&client_id={c.ClientId}&client_secret={c.ClientSecret}", null);
        var t = await resp.Content.ReadFromJsonAsync<BhTokens>();
        await _store.SaveRefreshTokenAsync(t.RefreshToken);    // rotated — overwrite the old one
        return t;                                              // then redo RestLoginAsync for a fresh session
    }
}

A.2.2 — Read: job, candidate, and the CV itself

// Illustrative excerpt. All entity calls are relative to session.RestUrl; append &BhRestToken=.
public sealed class BullhornClient(HttpClient http, BhSession session)
{
    private string U(string path) => $"{session.RestUrl}{path}" +
        (path.Contains('?') ? "&" : "?") + $"BhRestToken={session.BhRestToken}";

    // fields= (or layout=) is REQUIRED — omit it and Bullhorn returns 404, not a guess.
    public Task<BhEntity<JobOrder>> GetJobAsync(string id) =>
        http.GetFromJsonAsync<BhEntity<JobOrder>>(U(
            $"entity/JobOrder/{id}?fields=id,title,status,clientCorporation,clientContact,employmentType"));

    public Task<BhEntity<Candidate>> GetCandidateAsync(string id) =>
        http.GetFromJsonAsync<BhEntity<Candidate>>(U(
            $"entity/Candidate/{id}?fields=id,firstName,lastName,email,status,occupation"));

    // CV in two moves: list attachments (find isResume), then download the file.
    public Task<FileList> ListAttachmentsAsync(string candidateId) =>
        http.GetFromJsonAsync<FileList>(U(
            $"entity/Candidate/{candidateId}/fileAttachments" +
            "?fields=id,name,contentType,fileSize,type,dateAdded,isResume"));

    public Task<FileEnvelope> DownloadFileAsync(string candidateId, string fileId) =>
        http.GetFromJsonAsync<FileEnvelope>(U($"file/Candidate/{candidateId}/{fileId}"));
        // -> { File: { name, contentType, fileContent (base64) } }   (/raw for raw bytes)

    // Cleaner for screening: parse the resume and ask Bullhorn to populate the description as text.
    // populateDescription=text returns the resume body as flat text. (If you just want plain text and
    // no parsed Candidate, POST /resume/convertToText takes pdf|doc|docx|html|rtf|odt instead.)
    // For parseToCandidate the format param is the INPUT text format: "text" or "html" only.
    public async Task<string> ParseResumeToTextAsync(byte[] file, string format /* text | html */)
    {
        using var form = new MultipartFormDataContent { { new ByteArrayContent(file), "file", "cv" } };
        var resp = await http.PostAsync(U(
            $"resume/parseToCandidate?format={format}&populateDescription=text"), form);
        var parsed = await resp.Content.ReadFromJsonAsync<ParsedResume>();
        return parsed.Candidate.Description;   // resume body, flattened to text
    }

    // Search vs query — pick deliberately (Ch.4):
    //   search/ = Lucene index, eventually consistent (great for discovery, even inside CV text)
    //   query/  = JPQL on the DB, strongly consistent (use for read-after-write)
    public Task<SearchResult<Candidate>> SearchCandidatesAsync(string lucene, int start = 0, int count = 20) =>
        http.GetFromJsonAsync<SearchResult<Candidate>>(U(
            $"search/Candidate?query={Uri.EscapeDataString(lucene)}" +
            $"&fields=id,firstName,lastName,occupation&start={start}&count={count}"));
}

A.2.3 — Write: the decision note (and why a status change is human-only)

// Illustrative excerpt. The agent writes a NOTE (advice). It never flips a status (action).
public async Task<ChangeResult> WriteScreeningNoteAsync(string candidateId, string jobId, string summary)
{
    var body = new
    {
        action = "AI Screen",
        comments = summary,                                  // visible reasoning — a human reads this
        personReference = new { id = int.Parse(candidateId) },
        jobOrder        = new { id = int.Parse(jobId) }
    };
    var resp = await http.PutAsJsonAsync(U("entity/Note"), body);   // PUT = create
    return await resp.Content.ReadFromJsonAsync<ChangeResult>();
    // -> { changedEntityId, changeType: "INSERT" }
}

// A status change would be POST entity/Candidate/{id} { status = "Placed" } — and the agent
// is NOT allowed to call it. The leash lives in the write path: triage in, decision out by a human.

Bullhorn gives you a session, not a key. The whole client is built around earning that session, holding it, and re-earning it on a 401 — quietly, without a human in the loop.

A.3 JobAdder client: auth, read, write

JobAdder's auth is a clean OAuth2 authorization-code flow with one twist worth internalising: the token response hands you a per-account api base URL — and that, not a hardcoded host, is what every call must be prefixed with. The refresh token rotates on every refresh, so you persist the new one each time.

A.3.1 — Auth: code exchange, then rotating refresh

// Illustrative excerpt — not a copy-paste product.
public sealed class JobAdderAuth(HttpClient http, ITokenStore store)
{
    private const string IdHost = "https://id.jobadder.com";        // authorize + token live here

    // CODE EXCHANGE — auth code is valid ~5 min. Returns the tokens AND the per-account api base URL.
    public async Task<JaTokens> ExchangeCodeAsync(string code, JaCreds c)
    {
        var resp = await http.PostAsync($"{IdHost}/connect/token",
            new FormUrlEncodedContent(new Dictionary<string, string>
            {
                ["grant_type"]    = "authorization_code",
                ["client_id"]     = c.ClientId,
                ["client_secret"] = c.ClientSecret,
                ["code"]          = code,                           // reusing a code -> invalid_grant
                ["redirect_uri"]  = c.RedirectUri,
            }));
        var t = await resp.Content.ReadFromJsonAsync<JaTokens>();
        // -> { access_token, expires_in: 3600, token_type: "Bearer", refresh_token,
        //      api: "https://api.jobadder.com/v2" }   <- prefix ALL calls with this, never hardcode
        await store.SaveTokensAsync(t);                            // persist refresh_token + api base
        return t;
    }

    // REFRESH — access_token lives ~60 min. Needs the offline_access scope. Returns a NEW
    // access_token AND a NEW refresh_token (rotating) + a fresh api URL — persist all of it.
    public async Task<JaTokens> RefreshAsync(JaCreds c)
    {
        var rt = (await store.GetTokensAsync()).RefreshToken;
        var resp = await http.PostAsync($"{IdHost}/connect/token",
            new FormUrlEncodedContent(new Dictionary<string, string>
            {
                ["grant_type"]    = "refresh_token",
                ["client_id"]     = c.ClientId,
                ["client_secret"] = c.ClientSecret,
                ["refresh_token"] = rt,                            // single-use: this one is now spent
            }));
        var t = await resp.Content.ReadFromJsonAsync<JaTokens>();
        await store.SaveTokensAsync(t);   // rotated refresh_token + fresh api base — overwrite the old
        return t;
        // The authorize step is GET https://id.jobadder.com/connect/authorize
        // (response_type=code, client_id, scope, redirect_uri, state). Scopes: read write
        // offline_access + fine-grained (read_job, read_candidate, write_note, ...).
    }
}

A.3.2 — Read: job, candidate, CV (prefix every call with the stored api base)

// Illustrative excerpt. apiBase is the per-account "api" URL from the token response.
public sealed class JobAdderClient(HttpClient http, string apiBase, string accessToken)
{
    private HttpRequestMessage Req(HttpMethod m, string path)
    {
        var r = new HttpRequestMessage(m, $"{apiBase}/{path}");     // e.g. https://api.jobadder.com/v2
        r.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        return r;
    }

    // Job requirements live in skillTags.tags (JobOrderSkillTags = { matchAll, tags[] }).
    public async Task<JaJob> GetJobAsync(string jobId)
    {
        var resp = await http.SendAsync(Req(HttpMethod.Get, $"jobs/{jobId}"));
        return await resp.Content.ReadFromJsonAsync<JaJob>();       // { jobId, title, skillTags{tags}, ... }
    }

    public async Task<JaCandidate> GetCandidateAsync(string id)
    {
        var resp = await http.SendAsync(Req(HttpMethod.Get, $"candidates/{id}"));
        return await resp.Content.ReadFromJsonAsync<JaCandidate>(); // { firstName, lastName, email, skillTags[], education[], ... }
    }

    // CV in two moves: list resume attachments (latest=true), then download the raw file.
    public async Task<JaAttachmentList> ListResumesAsync(string candidateId)
    {
        var resp = await http.SendAsync(Req(HttpMethod.Get,
            $"candidates/{candidateId}/attachments?type=Resume&latest=true"));
        return await resp.Content.ReadFromJsonAsync<JaAttachmentList>();
        // each -> { attachmentId, type, category, fileName, fileType }
    }

    public async Task<byte[]> DownloadResumeAsync(string candidateId, string attachmentId)
    {
        var resp = await http.SendAsync(Req(HttpMethod.Get,
            $"candidates/{candidateId}/attachments/{attachmentId}"));
        return await resp.Content.ReadAsByteArrayAsync();           // raw binary — parse it locally (A.5)
    }

    // NOTE: there is NO parsed-resume-text endpoint. The closest is resume full-text SEARCH:
    // GET /candidates?keywords=... searches within each candidate's latest resume. Parsing to
    // text is your job, on your own infrastructure — which is exactly where the allowlist wants it.
    public async Task<JaPage<JaCandidate>> SearchCandidatesAsync(string keywords)
    {
        var resp = await http.SendAsync(Req(HttpMethod.Get,
            $"candidates?keywords={Uri.EscapeDataString(keywords)}&offset=0&limit=20"));
        return await resp.Content.ReadFromJsonAsync<JaPage<JaCandidate>>();
    }
}

A.3.3 — Pagination: offset/limit, but follow links.next

// Illustrative excerpt. List endpoints page with offset + limit (limit max 1000; limit=0 = count only).
// The response is an envelope { items, totalCount, links{first,prev,next,last} } — prefer the links.
public async IAsyncEnumerable<JaCandidate> AllCandidatesAsync(
    [EnumeratorCancellation] CancellationToken ct)
{
    string path = "candidates?offset=0&limit=1000";
    while (path is not null)
    {
        var resp = await http.SendAsync(Req(HttpMethod.Get, path), ct);
        var page = await resp.Content.ReadFromJsonAsync<JaPage<JaCandidate>>(cancellationToken: ct);
        foreach (var c in page.Items) yield return c;
        path = page.Links.Next;            // walk next until it's null — don't hand-roll offsets
    }
}

A.3.4 — Write: the decision note (same leash discipline)

// Illustrative excerpt. The agent posts a NOTE; a human owns the outcome. JobAdder has no separate
// "activity" resource — activities ARE notes. A status change is PUT /candidates/{id}/status, and
// the agent is NOT allowed to call it: triage in, decision out by a human.
public async Task WriteScreeningNoteAsync(string candidateId, string summary)
{
    var req = Req(HttpMethod.Post, $"candidates/{candidateId}/notes");
    req.Content = JsonContent.Create(new
    {
        text = summary,                    // REQUIRED — visible reasoning, a human reads this
        type = "AI Screen",
    });
    var resp = await http.SendAsync(req);
    resp.EnsureSuccessStatusCode();        // -> 201 NoteModel. POST /jobs/{jobId}/notes is the job-side twin.
}

JobAdder hands you a per-account api base URL and a refresh token that rotates on every use. Prefix every call with the one, persist the other on every refresh — fumble either and you're back to the interactive flow, which is exactly the kind of "small detail that quietly breaks in month four" that running an agent in production is all about.

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