An approval queue for agent-drafted outreach

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_body and sets state='edited_sent'. Keep both versions. The diff between body and edited_body is 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:

  1. Drafts per recruiter per day. Above about a dozen and the queue stops being read.
  2. Send rate. Sent plus edited-sent over total decided. Below half, the triggers are wrong, not the writing.
  3. 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.
  4. 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.