Interview scheduling without the back-and-forth: holds, expiry and a reschedule path

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:

  1. Store every instant as timestamptz in UTC.
  2. Store the participant's IANA timezone name — America/Denver, not MST, not an offset. Offsets go stale twice a year; a healthcare client in Phoenix will find that out for you.
  3. 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:

StateMeansNext
proposedDraft times sitting in the approval queueheld on send, dropped if the recruiter rejects
heldCandidate has the options, slots reservedconfirmed, expired, released
confirmedEveryone accepted, invite sent, ATS updatedrescheduled, cancelled, completed
rescheduledNew hold created, old one releasedas held
cancelledCalled off by either sideterminal, 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.