Ask an owner where days go between submission and interview and you get the same answer: nobody is stuck on a decision, everyone is stuck on a calendar. The hiring manager offers Tuesday. The recruiter relays it. The candidate is on shift until six. Two more emails, and Tuesday is gone.
That gap is worth money. A contractor placed three days sooner is three days of bill rate, and in a market where two agencies are submitting the same person, the interview that gets booked first often ends the race. It is also one of the few workflows where an agent does something narrow and checkable: read availability, propose times, hold a slot, confirm it, log it.
This walkthrough builds that agent. It assumes an ATS with an API (Loxo, Crelate, JobAdder, Vincere, Recruiterflow), recruiters on Google Workspace or Microsoft 365, and no appetite for letting software email a client unsupervised on day one.
What the agent is allowed to do
Write this down before any code, because it is the line reviewers and clients will ask about:
- Reads recruiter and interviewer free/busy, the req, the candidate record and the submission.
- Proposes two or three slots that fit everyone's working hours.
- Holds a slot for a fixed window so a second candidate is not offered the same time.
- Drafts the messages. A named recruiter sends them, at least until the agent has earned otherwise.
- Writes the confirmed interview back to the ATS as an activity, with who scheduled it.
It does not decide who gets interviewed, does not negotiate with the client, and does not chase a candidate who has asked to be left alone. Those stay with people.
Step one: read free/busy, not calendar contents
You need one bit per time block — busy or not — and nothing else. Both major platforms expose exactly that, and asking only for it keeps you out of a conversation about why your integration can read a partner's medical appointment.
On Google Workspace, the Calendar freebusy.query method takes a window and a list of calendar ids:
curl -s -X POST "https://www.googleapis.com/calendar/v3/freeBusy" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"timeMin": "2026-09-21T00:00:00Z",
"timeMax": "2026-09-26T00:00:00Z",
"timeZone": "America/New_York",
"items": [{"id": "dana@agency.example"}, {"id": "pm@agency.example"}]
}'
The response is a list of busy intervals per calendar. On Microsoft 365 the equivalent is getSchedule on the Graph calendar API, which returns availability strings per interval. Either way you scope the application to the read-only calendar scope and log every call, the same way you would scope an agent's ATS access.
What free/busy will not tell you: that Tuesday morning is blocked out for the weekly pipeline meeting that everyone skips, or that Dana does not do interviews before ten. Encode that yourself.
create table scheduling_rules (
person_email text primary key,
time_zone text not null, -- IANA, e.g. 'America/Chicago'
workday_start time not null,
workday_end time not null,
min_notice_hours int not null default 12,
buffer_minutes int not null default 15,
max_per_day int not null default 4
);
min_notice_hours stops the agent proposing a slot ninety minutes out. buffer_minutes stops it stacking interviews back to back. max_per_day exists because someone will otherwise book a recruiter six screens on a Thursday.
Step two: store time properly, once
More scheduling bugs come from timezones than from calendars. Three rules, and then stop thinking about it:
- Store every instant as
timestamptzin UTC. - Store the participant's IANA timezone name —
America/Denver, notMST, not an offset. Offsets go stale twice a year; a healthcare client in Phoenix will find that out for you. - Render local time at the last possible moment, and always print the zone in the message: "Tuesday 23 September, 10:00 AM Eastern (7:00 AM Pacific)".
A candidate who shows up an hour late to a client interview is not a scheduling inconvenience. It is a placement you lost.
Step three: a holds table with a TTL
The piece most teams skip. If the agent proposes 10:00 Tuesday to two candidates for the same req, one of them will get an apology email. A hold is a soft reservation with an expiry.
create table interview_holds (
id bigserial primary key,
req_id text not null,
candidate_id text not null,
interviewer text not null references scheduling_rules(person_email),
slot_start timestamptz not null,
slot_end timestamptz not null,
state text not null default 'held', -- held|confirmed|released|expired
expires_at timestamptz not null,
created_by text not null, -- agent run id or recruiter
created_at timestamptz not null default now()
);
create unique index interview_holds_live
on interview_holds (interviewer, slot_start)
where state in ('held', 'confirmed');
The partial unique index does the real work: two live rows cannot claim the same interviewer and start time, so the race between a Monday-morning agent run and a recruiter booking by hand resolves in the database rather than in someone's inbox.
Give holds a short life — four to twenty-four hours, depending on how fast your candidates reply — and expire them on a schedule:
update interview_holds
set state = 'expired'
where state = 'held'
and expires_at < now();
When a hold expires, the agent tells the recruiter it expired. Silence here is how a slot sits reserved for a week against a candidate who went quiet.
Step four: the state machine
Keep the states few enough to draw on a whiteboard:
| State | Means | Next |
|---|---|---|
proposed | Draft times sitting in the approval queue | held on send, dropped if the recruiter rejects |
held | Candidate has the options, slots reserved | confirmed, expired, released |
confirmed | Everyone accepted, invite sent, ATS updated | rescheduled, cancelled, completed |
rescheduled | New hold created, old one released | as held |
cancelled | Called off by either side | terminal, with a reason |
Every transition writes a row: what changed, when, which agent run or which human. That log answers "why did this candidate get two invites" in about thirty seconds, and it is the same discipline as the approval queue for agent-drafted outreach — the drafts go through a person, and the person is recorded.
Step five: the reschedule path
Roughly one interview in five moves. Build for it on day one or your agent will be a tool that works until a client has a fire drill.
A reschedule is not an edit. It is: release the old hold, create a new one, update the calendar event in place so the existing invite moves rather than spawning a duplicate, and write a new ATS activity that references the old one. Two practical notes:
- Keep the calendar event id on the confirmed row. Updating an existing event keeps the thread and the accept/decline state; creating a second event gets you a client with two entries and no confidence in either.
- Cap automatic reschedules at one. After that, the agent hands the thread to the recruiter with a one-line history. A second slip usually means something is wrong that no amount of calendar arithmetic fixes.
Cancellations get a reason code — client_cancelled, candidate_withdrew, role_on_hold, no_show. Those codes are worth more than the scheduling itself after a quarter of data.
Step six: contacting the candidate
If slot options go out by text, the consent and revocation records still apply exactly as they do for any other outbound message — same rules as texting contractors. A scheduling message is not exempt because it is useful.
And check the reply path. A candidate who answers "can't do Tuesday, Wednesday works" must land in a human's queue, not in a parser that shrugs. Free-text reply handling is where a scheduling agent quietly starts dropping people.
What to measure
Four numbers, from the tables above:
- Hours from submission to confirmed interview, median, before and after.
- Slot acceptance rate — how often the first set of proposed times works. Below about half, your rules are wrong, not your model.
- Expired holds as a share of holds created.
- No-show rate, split by whether a reminder went out.
Baseline them for a fortnight before the agent goes live. Without a before, you have an anecdote.
When not to build this
If your interviews are one a week, or if the client insists on their own scheduling portal, this does not pay back — not yet. The workflow earns its keep at volume: high-turnover contract desks, phone screens, healthcare and light industrial shifts where a day of delay loses a candidate to whoever called second.
And if your submissions are late because nobody has read the req, no calendar integration will help. That is a different fix.
If you want this wired to your ATS and your calendars, with the holds, the reschedule path and the log, contact us and name your ATS, your calendar platform and roughly how many interviews a week you book. We reply within one business day.