A redeployment agent's last step is not sending an email. It is putting a drafted message in front of the recruiter who owns the relationship, and waiting.
That waiting room is a queue, and it is where most of the engineering actually is. The drafting is a prompt and a template. The queue is state: who owns this draft, whether the candidate should be contacted at all, what happens when nobody looks at it for four days, and what lands on the ATS record after a recruiter hits send.
This walkthrough builds one. The SQL is Postgres and the tables are deliberately small; adapt the names to whatever your ATS export calls things.
Step one: decide before you draft
The cheapest way to avoid an embarrassing message is to never generate it. Run the suppression checks before the model sees the candidate, not after.
Four checks cover most of it:
- Consent and opt-out. The candidate has not unsubscribed, and their marketing or contact preference in the ATS still permits outreach.
- Ownership. Somebody at the firm already owns this relationship. The draft goes to them, not to whoever the round-robin picks.
- Recency. No human at the firm has contacted this candidate in the last N days, N being a number the operations lead sets and changes.
- Status. The candidate is not already placed, already in process on another req, or flagged do-not-contact by a client.
Keep these in a table, not in code, because they change monthly:
create table outreach_rules (
rule_key text primary key,
rule_value int not null,
updated_by text not null,
updated_at timestamptz not null default now()
);
insert into outreach_rules (rule_key, rule_value, updated_by) values
('min_days_since_last_contact', 21, 'ops@firm'),
('draft_expiry_days', 4, 'ops@firm'),
('max_drafts_per_recruiter_day',12, 'ops@firm');
That last rule matters more than it looks. An agent that can generate two hundred drafts overnight will produce a queue nobody opens, and a queue nobody opens is worse than no agent: it hides the twelve messages that were worth sending.
Step two: one row per draft, with a state
create table outreach_draft (
draft_id bigserial primary key,
candidate_id text not null,
placement_id text,
job_id text,
reason_code text not null,
channel text not null default 'email',
subject text,
body text not null,
model_version text not null,
prompt_version text not null,
inputs_hash text not null,
state text not null default 'pending',
assigned_to text not null,
claimed_by text,
claimed_at timestamptz,
decided_by text,
decided_at timestamptz,
edited_body text,
ats_note_id text,
created_at timestamptz not null default now(),
expires_at timestamptz not null,
constraint outreach_draft_state_ck check (state in
('pending','claimed','sent','edited_sent','rejected','expired','failed'))
);
create unique index outreach_draft_open_uq
on outreach_draft (candidate_id, job_id)
where state in ('pending','claimed');
The partial unique index is the part to keep. It stops the agent drafting the same candidate for the same req twice because a sync ran late, which is the failure that makes a recruiter turn the whole thing off.
reason_code is a short enumerated string: end_date_30d, assignment_ended_unfilled, req_match_new. Recruiters skim it before they read the message, and it gives you something to group by later when you want to know which trigger actually converts.
model_version, prompt_version and inputs_hash cost nothing now and answer the only question that matters after a bad draft: what produced this. inputs_hash is a digest of the exact fields handed to the model — candidate id, end date, req id, rate — so you can prove what the agent knew without keeping a second copy of the candidate record.
Step three: claim, do not just assign
Assignment says who should look. Claiming says who is looking right now. Without the second one, two recruiters open the same queue on Monday and one candidate gets two versions of the same message.
update outreach_draft
set state = 'claimed',
claimed_by = $1,
claimed_at = now()
where draft_id = (
select draft_id
from outreach_draft
where state = 'pending'
and assigned_to = $1
and expires_at > now()
order by created_at
for update skip locked
limit 1
)
returning draft_id, candidate_id, subject, body, reason_code;
for update skip locked is doing the work: two concurrent calls get two different rows instead of one deadlock. Release the claim if the recruiter walks away — a small job that moves claimed rows older than thirty minutes back to pending is enough.
Step four: three buttons, and what each one writes
The reviewer gets send, edit and send, reject. Nothing else. No "approve all", no autoplay.
- Send sets
state='sent',decided_by,decided_at, and hands the message to the mail step. - Edit and send stores the recruiter's text in
edited_bodyand setsstate='edited_sent'. Keep both versions. The diff betweenbodyandedited_bodyis the best training signal you will get, and after a month it tells you plainly whether the drafting is worth its API bill. - Reject requires a reason from a short list: wrong candidate, wrong req, tone, already spoken, bad data. Free-text optional, list mandatory. Rejection reasons are how you fix the agent; a rejection with no reason is a shrug you cannot query.
Send from the recruiter's own mailbox, with the recruiter as the sender, and route replies to them. The agent drafts. A named person sends. That distinction is the whole product, and it is also what keeps the outreach out of the awkward conversation about who exactly emailed a candidate.
Step five: expire the stale ones
A draft about a contractor finishing on the 26th is worthless on the 27th.
update outreach_draft
set state = 'expired'
where state in ('pending','claimed')
and expires_at <= now();
Set expires_at from the trigger, not from a global constant: an end-date draft expires the day the assignment ends, a new-req match expires when the req is filled or pulled. Then watch the expiry rate. If more than a fifth of drafts expire unread, the queue is too big, the assignment is wrong, or the recruiters do not believe the drafts are any good. All three are worth knowing before you build the next agent.
Step six: write back where the recruiter looks
A sent message that lives only in your database did not happen. Post it to the ATS as an activity or note on the candidate record, with the req reference, and store the returned id in ats_note_id.
Make the write-back idempotent — key it on draft_id — because the send step and the note step will eventually fail at different moments, and the recovery you want is "retry until the note exists", not "three identical notes on a candidate the recruiter is about to call".
Give the integration its own ATS user with the narrowest scopes your vendor offers: read placements, jobs and candidates, write notes. Not a spare recruiter licence, and not an admin key. When somebody asks in six months who wrote this note, the answer should be a name that is obviously a system, plus a decided_by in your own table that is obviously a person.
The four numbers to watch
At the end of the first month, four things tell you whether to keep going:
- Drafts per recruiter per day. Above about a dozen and the queue stops being read.
- Send rate. Sent plus edited-sent over total decided. Below half, the triggers are wrong, not the writing.
- Edit rate. High edit rate with a high send rate is fine — the drafting is saving the blank page, which was most of the value anyway.
- Expiry rate. The honest measure of whether anyone is using this at all.
None of these require a new dashboard. They are four queries against one table, mailed on Monday with the redeployment report.
What this is not
This is not a sequencer, and the queue is not a growth loop. Adding automatic follow-ups is the moment an agent stops drafting and starts sending, and that is a different conversation with your legal counsel and your candidates about consent, frequency and record-keeping.
If the honest answer is that your recruiters will not open a queue at all, the drafting agent is not the first build. Not yet. Start with the report that tells them who is finishing, watch whether they act on it, and add the drafts once somebody is annoyed at retyping the same message eleven times a week.