Most agent projects in a staffing back office stall in the same place, and it is not the model. It is the moment someone asks what this thing will be allowed to see, and the honest answer is "everything the admin account can see", because the build started with an API key copied out of a settings screen.
That answer is fine for a prototype on a Tuesday. It is not fine for a system that reads candidate records for a living, and it is the wrong starting point if you ever have to reconstruct who looked at what. This walkthrough builds the access layer first: an identity, a scope, a tool surface, and a log. It is dull work and it takes about a day. Do it before the interesting part.
Step one: the agent is a named user, not a borrowed one
Do not run an agent as a recruiter's login. Two reasons, and the second is the one that bites.
The first is obvious: when that recruiter leaves and their account is disabled, your nightly sync dies on a Saturday.
The second is that every note, every audit trail entry, every "last modified by" in the ATS will carry that recruiter's name. Six months later a client asks why a candidate was passed over, and the record says a human made a change at 03:14. That is a bad conversation to have, and it was avoidable.
So: one service identity per agent, named for the job it does.
| Identity | Runs | Reads | Writes |
|---|---|---|---|
svc-redeploy | Nightly end-date sync, matching | Placements, jobs, candidate summary | Draft notes on candidate, nothing else |
svc-chaser | Hourly timesheet and credential chase | Timesheets, credential records, contacts | Task records, chase log |
svc-screener | On new application | Application, resume, job rubric | Score record with rationale |
Three identities, three blast radii. If the chaser is misconfigured it cannot touch a screening record, and you can say so with a straight face rather than as a hope.
Most ATSs charge per seat, so this is a real conversation with your vendor. Some have an integration or API user concept that is not billed as a recruiter seat. Ask before you design around it; if the answer is that every identity is a paid seat, one shared svc-agents user with a narrow scope still beats a recruiter's login.
Step two: scope the credential, not just the code
If your ATS speaks OAuth 2.0, the scopes you request at consent time are the only limit that survives a bug in your own code. Request the narrow set and refuse the tempting one.
# Ask for what the redeployment agent actually needs.
# Not read_write on everything, because it was easier.
SCOPES="read_placement read_job read_candidate write_candidate_note offline_access"
curl -s -X POST "$TOKEN_ENDPOINT" \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN" \
-d scope="$SCOPES" \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET"
Scope names differ on Loxo, Crelate, JobAdder, Vincere and Recruiterflow, and some systems only offer coarse read and write. Write down what you actually got. A one-page table of system, identity, granted scope and what that scope covers in practice is the deliverable; it is also most of the data-access map in a Workflow Assessment.
Where the platform gives you nothing but a full-access key, the limit has to move into the tool layer below, and you should note that in the map as a known weak point rather than pretending the problem is solved.
Step three: give the model tools, not an API
The model should never hold the credential and never compose a URL. Put a thin function layer in between: each function does one thing, takes typed arguments, and is the only path to the ATS. This is the pattern that tool-calling APIs and the Model Context Protocol both formalise, but you do not need either to get the benefit. A module with five functions and no requests.get outside it is the whole idea.
# tools.py -- the only module that holds the token.
ALLOWED_NOTE_TYPES = {"redeployment-draft"}
def list_placements_ending(days: int) -> list[dict]:
"""Read-only. Active placements ending within `days`."""
if not 0 < days <= 120:
raise ValueError("days out of range")
rows = _get("/placements", params={"status": "Active", "ending_within": days})
audit("list_placements_ending", scope=f"{days}d", n=len(rows))
return [_trim(r) for r in rows]
def draft_note(candidate_id: int, note_type: str, body: str) -> dict:
"""Write. Draft note only; never sends, never changes status."""
if note_type not in ALLOWED_NOTE_TYPES:
raise PermissionError(f"note type {note_type} not permitted")
if len(body) > 4000:
raise ValueError("note too long")
res = _post(f"/candidates/{candidate_id}/notes",
json={"type": note_type, "body": body, "status": "draft"})
audit("draft_note", candidate_id=candidate_id, note_id=res["id"])
return res
Three properties worth keeping:
- Read and write are different functions with different names. No
update_record(entity, id, payload)convenience wrapper. That signature is a full-access key wearing a hat. _trimexists. Return the fields the agent needs and drop the rest. There is no reason for a matching step to receive a date of birth, a home address or a national identifier, and every reason for it not to.- No function sends anything to a candidate or a client. Drafts go to a person, which is the same boundary the approval queue enforces further downstream.
If a step in your workflow genuinely needs an ad-hoc query, write a new named function for it. The moment you add a generic escape hatch, the scope table you built in step two becomes fiction.
Step four: log the access, not just the outcome
Most teams log what the agent decided. Fewer log what it read. The second one is what you need when a candidate exercises a right, when a client asks how their req data is handled, or when you are reconstructing a decision under NYC Local Law 144 or Illinois HB 3773. The decision record explains the reasoning; the access log explains the reach.
create table agent_access_log (
id bigserial primary key,
occurred_at timestamptz not null default now(),
identity text not null, -- 'svc-redeploy'
run_id uuid not null, -- one agent run, many calls
tool text not null, -- 'draft_note'
direction text not null check (direction in ('read','write')),
subject_type text, -- 'candidate' | 'placement' | 'timesheet'
subject_id bigint,
args_digest text, -- hash, not the payload
outcome text not null, -- 'ok' | 'denied' | 'error'
detail text
);
create index on agent_access_log (subject_type, subject_id, occurred_at desc);
create index on agent_access_log (run_id);
Store a digest of the arguments rather than the arguments. The log is meant to outlive the records it points at, and a log full of copied candidate data is a second copy of your candidate database with worse access controls. Keep it on the same retention clock you set for screening records.
The two queries that earn the table:
-- Everything any agent did to one candidate, in order.
select occurred_at, identity, tool, direction, outcome, detail
from agent_access_log
where subject_type = 'candidate' and subject_id = 88112
order by occurred_at;
-- Denials in the last week, grouped. A rising count is a
-- misconfiguration or a workflow that outgrew its scope.
select identity, tool, count(*) as denials
from agent_access_log
where outcome = 'denied'
and occurred_at > now() - interval '7 days'
group by 1, 2
order by denials desc;
That second query is the one to put on a dashboard. Denials are not noise. They are either a bug you have not found or a person quietly widening the job the agent was scoped for.
Step five: rehearse the revocation
The access layer is not finished until you have turned it off once. Pick a Wednesday afternoon, revoke the refresh token for one identity, and watch what happens.
You are checking three things. The agent fails closed rather than falling back to some other credential it found. The failure reaches a named human within an hour instead of appearing in a log nobody reads. And re-consent takes minutes, because the runbook exists and someone at the firm has the admin rights to do it.
Write down how long it took. That number is your answer when a client's security questionnaire asks about credential revocation, and it is a much better answer than a paragraph.
What this does not cover
This is the access layer for agents you build and run. It says nothing about the access your ATS vendor's own AI features have to the same data, which is governed by your contract with them and not by anything in your codebase. Read those terms separately; the data flowing out through a vendor feature is not visible in the table above.
It also is not a security programme. Secrets management, network egress, log retention and vendor review are all their own work. What it does give you is the ability to answer, precisely and from a query, which agent read which record and when. In a business that holds candidate data for a living, that answer is worth a day.
If you want the data-access map done properly before the first agent is built, that is the first half of a Workflow Assessment. Contact us and name your ATS and your back-office stack.