Overview
Once a candidate is added to a job, Tenzo decides when and how to reach out based on a stack of gates: stage, consent, business hours, cooldown, retry budget, and channel-specific guards (phone line type, ATS sync state). Every outbound attempt has to pass every gate. This page describes the rules. For the consent rules in isolation, see Candidate Consent. For the broader stage flow, see Candidate Stage Lifecycle.When Outreach Starts
A candidate becomes eligible for outreach when their tenzo_stage transitions toOUTREACH. Two paths get them there:
- Resume passes —
AWAITING_RESUME_REVIEW → OUTREACHafter the resume scorer accepts the candidate against the job’s requirements. - Recruiter override —
RESUME_REJECTED → OUTREACHwhen a recruiter manually clicks Progress to Interview. Any scheduled resume-feedback email (or rejection email, if one was scheduled) is cancelled at the same time.
OUTREACH, a PENDING call row is created idempotently. If a queued or in-flight call already exists (PENDING, INVITED, INITIATED, CALLING, CONNECTED, or AWAIT_PROCESSING), Tenzo does not create a second row. An existing INVITED web call is adopted by the transition: its schedule is refreshed when needed, and a concurrent terminal-stage reconciliation cancellation is repaired before OUTREACH commits. The scheduled_datetime on that row is what the call dispatcher polls for.
A candidate added to a job who is already being contacted on another job
at the same org never goes to
OUTREACH directly — they go to COOLING_DOWN
first. See Cooldown below.Scheduling the First Attempt: Outreach Hours
The firstscheduled_datetime is computed from the org’s Outreach Hours settings and the candidate’s timezone.
- Timezone:
candidate.timezoneon the candidate row. The field is populated at candidate intake in this order: (1) explicit value from the ATS/import, (2) zip-code geocoding, (3) the location point (offline lat/lon lookup, e.g. a city/state centroid when there is no zip), (4) phone area-code lookup. Ifcandidate.timezoneis empty at outreach time, the scheduling code falls back in this order: phone area-code lookup → the org’s default candidate timezone (if configured) →Org.timezone. Blankdefault_candidate_timezonetherefore inherits the organization timezone rather than a hard-coded US Eastern default.UTCis used only when the org row cannot be loaded orOrg.timezoneis corrupt. A timezone the candidate has confirmed directly (call, SMS, or action token) outranks all derived sources for 90 days; after that the confirmation expires, so location evidence can update the value again and call/SMS flows will re-confirm the timezone with the candidate. - Weekday window: Configurable per org (default
08:00–21:00candidate-local). - Weekend window: Configurable per org.
call_on_weekendsis on by default and weekend hours default to the same window as weekdays. Ifcall_on_weekendsis off, weekend slots roll forward to Monday.
Initial Outreach Method
Each job has aninitial_outreach_method setting that biases the first contact:
Jobs can also set
override_outreach_hours_for_initial_outreach=True, which fires the first contact immediately without rolling forward to business hours. This is used by SMS-first and Outreach-Hours flows so an evening sync still gets a same-day touch.
Web-call jobs
For jobs whoseCampaign.call_types.applicant_call_type (or sourced_call_type) is CallType.WEB, every PENDING Call row is created with web_call=True. Production invite sending runs through poll_web_call_invites → dispatch_claimed_call(..., WEB_INVITE_FIRST): the same outreach eligibility evaluator used for phone dispatch (including ATS org-policy when ats_candidate_id is set). ATS check failures defer with bounded retry rather than sending. After eligibility and a web-specific SMS-or-email consent gate, dispatch calls invite_to_web_call, which creates a WebCallLink row and POSTs the internal /send_web_invite_email endpoint. The email template comes from the customer’s Cosmos script_info.webCallEmailBody / webCallEmailSubject and includes a candidate-specific link to the scheduling/start page.
A legacy short-circuit at the top of make_initiate_call_request still routes accidental web_call=True rows on the phone path into invite_to_web_call; the dedicated web poller no longer relies on that path.
Enrollment transient ATS parks: If first enrollment cannot create an ATS client, the candidate is fail-closed into SKIP_OUTREACH with no scheduled retry. Application-backed jobs recover when ATS application sync clears the transient park. Candidate-only / manual jobs without application sync recover only when add_to_campaign runs again while the transient history marker is still latest (tearsheet redelivery, UI re-add, etc.).
Reminder emails respect outreach hours and the configured VideoInterviewReminderSettings cadence. Link expiration is governed by VideoInterviewLinkExpirationSettings (default: no expiration). When the candidate clicks the link and schedules, the stage transitions to AI_INTERVIEW_SCHEDULED.
Web-call invite outreach does not use the phone voicemail retry budget or Max Attempts Tenzo stages. Those candidates stay in Outreach while invites, reminders, or a scheduled interview slot are in flight. Once the configured invite/reminder budget is exhausted with no response, they move to the All Invites Sent soft-wait stage: no further invites are sent, but the emailed link stays live, so the candidate can still schedule, complete, decline, or opt out. When outreach goes stale in either state, the inactivity disposition sweep moves them to Expired (not Max Attempts). Phone-style retry exhaustion (MAX_ATTEMPTS_NO_INTERVIEW / MAX_ATTEMPTS_NOT_COMPLETED) applies only to outbound phone attempts.
Channel Selection at Send Time
When the dispatcher picks up aPENDING call, it re-evaluates channel right before sending:
LANDLINE, PAGER, VOICEMAIL, PREMIUM, SHARED_COST, or FIXED_VOIP. Non-fixed VoIP numbers (e.g. Google Voice) can receive SMS and are allowed, matching call behavior. Tenzo looks the number up against Twilio and caches the result for 30 days. If the lookup is missing or fails, the SMS is allowed through.
Consent gate. Before any channel is used, consent is checked. See Candidate Consent for the full matrix, but the short version:
call_consent=Falseblocks calls only. SMS is independent of this flag and gated solely bysms_consent. Email is not gated by this flag either — email proceeds based onemail_consentalone.sms_consent=False(candidate-level or Workday application questionnaire) blocks SMS only. The system falls through to call and email when those channels still have consent.- Calling consent is
call_consent. email_consent=Falseblocks email (no further fallback).
sms_consent is False, Tenzo replaces the candidate’s first failed-call SMS with a configurable opt-in prompt (optInApplicantSms or optInSourcedSms) and transitions the SMS conversation to AWAITING_OPT_IN. Exact carrier opt-in keywords (START / UNSTOP) bypass the classifier in either opt-in state. After Tenzo’s fixed clarification prompt, exact YES / NO replies also bypass it; they are not deterministic against the initial configurable prompt, which may use inverted wording. Other replies are classified as opt-in / opt-out / unclear:
- Opt-in → grants
sms_consent=True, sendspostOptInSms, advances toINITIAL_OUTREACH. Normal outreach resumes. - Opt-out → routes through the same consent-revocation path as a literal
STOPreply: revokes text + call consent (keeps email) and movestenzo_stagetoCONSENT_REVOKED. - Unclear (first reply) → sends a clarification SMS, advances to
AWAITING_OPT_IN_CLARIFY. A second unclear reply defaults to opt-out.
postOptInSms are sent with allow_pre_consent_send=True. The flag does not bypass the sms_consent gate: the sender still checks consent, allows the send only for the opt-in message types (PRE_CONSENT_MESSAGE_TYPES, anything else raises), and logs pre_consent_send_redundant when the candidate already had consent. It also disables the email fallback — the consent ask must arrive on the SMS channel it’s asking about, not via email. Outside this narrow flow the gates above apply unchanged.
Retries and Cooldowns
Retry Budget
When a call ends without a conversation (voicemail, no-answer, busy, or declined), a newPENDING call is scheduled. The behavior comes from the job’s Call Retry Settings:
Each rescheduled call is re-bounded to outreach hours. When
retry_count is exhausted, the candidate’s stage transitions out of OUTREACH and no further calls are scheduled.
Consent-Gated Retry
If a call is cancelled at dispatch time because consent is missing (and the candidate can’t be reached via email fallback either), Tenzo schedules a consent-gated retry atretry_hours later — up to retry_count + 1 total attempts. This exists so that a consent flip during the retry window (e.g. the ATS sync adds opt-in, or the candidate texts back START) gets picked up.
Failure-Reschedule
A separate, short rescheduler kicks in when the call initiation itself fails (the call provider rejects the request, phone-provider error, etc.). The call is bumped 10 minutes forward and re-bounded to outreach hours.Cooldown Across Jobs
When a candidate is added to a job, Tenzo checks if they’re already in motion on another job at the same org. If so, the new job’sCandidateCampaignInfo enters COOLING_DOWN instead of OUTREACH. They auto-resume once the other outreach concludes, or after 14 days they expire to COOLDOWN_EXPIRED with no contact.
If outreach is manually paused on a candidate or because the whole job is paused, Tenzo moves that candidate to OUTREACH_PAUSED and parks pending outreach calls as PAUSED — except candidates who already have a pending CANDIDATE_RESCHEDULED phone callback, who stay in OUTREACH so the booked screen can still dial. On resume, Tenzo first checks interview-result reuse (if enabled and a matching interview was completed while paused, the candidate exits directly to CALL_COMPLETED or FAILED_KNOCKOUT, and unresolved work-accommodation review items then overlay PENDING_ACCOMMODATION_REVIEW on top of CALL_COMPLETED), then re-runs cooldown checks before returning to OUTREACH (or leaving the candidate in COOLING_DOWN if another job is still active).
Cooldown days are configurable per org by candidate source and call type:
The day count controls only the “called within window” trigger. Active outreach and queued attempts on another job always trigger cooldown, regardless of the day setting. See Cooldown across jobs for the full stage description.
What Stops Outreach
Outreach is halted — pending calls cancelled, no new ones created — when any of these become true for the candidate:Stage-based stops
If the candidate’s stage moves into any of the following, all non-terminal calls are markedNOT_INTERESTED and no new outreach is scheduled:
NOT_INTERESTED— candidate declined this job; can be re-engaged for other jobs.CONSENT_REVOKED— candidate revoked consent on the channel(s) they were reachable on (STOP / call / email / ATS sync), leaving no usable channel; blocks outreach on every job that relied on the revoked channel. Recoverable — re-consent, a recruiter override, or the candidate calling in and completing a graded interview re-engages outreach. See Consent Revocation below.FRAUD_REJECTEDDISPOSITIONED_IN_ATS(the ATS marked them rejected/withdrawn)USER_CANCELED(recruiter explicitly stopped outreach)FAILED_KNOCKOUTHUMAN_REVIEW_REQUESTEDAI_INTERVIEW_SCHEDULED(the candidate has scheduled a web call — typically by following an SMS invite link; phone outreach pauses until the web call lands)SKIP_OUTREACH(outreach is blocked before contact, either pre-dispatch or at dispatch time — e.g. an ATS-eligibility soft-block or a job-level AI Resume Review Only policy)COOLING_DOWN
INVALID_CONTACT is functionally a stop too, but it doesn’t sit in the blocking-stages list. It closes the call that surfaced the problem (cancelled with an “Invalid contact information” or “No email address” reason when nothing was ever dialed, INVALID_CONTACT when a provider refused a dial we placed) and then cancels any other queued legs for the same candidate–job pair post-commit, leaving live legs alone. Details under Invalid contact and other dispatch gates below.
Consent-based stops
call_consent=False→ no calls. SMS is unaffected (gated solely bysms_consent), and email is independent too — it still goes through ifemail_consent=Trueand an email integration is connected.has_any_consent(candidate)is the master outreach gate: True when any ofsms_consent,email_consent, orcall_consentis set. With all three false the candidate has no reachable channel and outreach is cancelled entirely. The stage depends on whether consent was ever present: the first time an application is ingested with no consent on file (it was never revoked) the candidate transitions toNO_CONSENT; a subsequent re-check of an existing application whose channel(s) are now gone (e.g. viaoutreach_blocking_service) transitions toCONSENT_REVOKED.
Retry exhaustion
Whenretry_count is reached without a successful phone conversation, the candidate exits OUTREACH into a max-attempts stage and no more outbound calls are scheduled. This does not apply to web-call invite jobs: those candidates stay in OUTREACH while invites and reminders are in flight, move to ALL_INVITES_SENT once the invite/reminder budget is exhausted with no response, and from either state are picked up by the inactivity timeout (EXPIRED) sweep — unless they first complete the interview or reach another terminal stage. The emailed link stays live throughout, so a candidate in ALL_INVITES_SENT can still complete, decline, or self-schedule.
Invalid contact and other dispatch gates
Beyond stage and consent, the dispatcher applies several operational gates at the moment aPENDING call comes due. These don’t move the candidate to a “stopped” stage in advance — they only fire when dispatch tries to send.
- Paused job. Routine dispatcher queries filter out calls whose campaign status is
PAUSED, so first-touch dials, voicemail retries, and other non-promised outreach do not fire. Exception: a candidate with a pendingCANDIDATE_RESCHEDULEDphone callback is left inOUTREACH(the call staysPENDING) —OUTREACH_PAUSEDis inSTAGES_BLOCKING_OUTBOUND_CALLSand would cancel the promised dial.pause_outreach_calls_actionalso leaves those rowsPENDING(exclude_initiated_by) as defense in depth. They are claimed byget_next_rescheduled_phone_call_set_initiated, which omits the paused-job filter. Booked video interviews are a separate gate:allows_paused_video_interviewsmust be on or initiation is rejected. Other outreach candidates who have not booked a time move toOUTREACH_PAUSEDwhile the job is paused; when resumed, interview-result reuse can complete the application immediately (CALL_COMPLETED/FAILED_KNOCKOUT, then overlayingPENDING_ACCOMMODATION_REVIEWif review items remain), otherwise they re-enterOUTREACHonly if cooldown allows it. - Missing or invalid phone. Phoneless candidates with email + email-consent get a single outreach email + a consent-gated retry, cancelled with
NO_PHONE_EMAIL_FALLBACK(analytics-distinct from the call-opt-out branch’sNO_CALL_CONSENT, but both share the same retry-budget filter). The shared body lives in_try_email_only_outreach; the two outer branches differ on their precondition and their failure-mode resolution. If email isn’t viable for a phoneless candidate (no email, no consent, orget_no_answer_email_taskno-op’d), the email-only branch is skipped/falls through — the downstream no-phone gate inmake_initiate_call_requestthen transitions the candidate toINVALID_CONTACT. Web-call jobs (Call.web_call=True) are excluded from this branch —invite_to_web_callhandles them with awebCallEmailinvite and doesn’t need a phone. - SMS invalid-number callback. When an SMS provider (Twilio or Telnyx) returns an invalid-number error code on send, the SMS failure policy transitions the candidate to
INVALID_CONTACT. This is a single-strike rule — one invalid-number response from the carrier halts SMS for that candidate. - SMS error threshold. Independent of invalid-number errors, repeated SMS failures against the same candidate-job pair (currently 10) disable further SMS attempts on that pair. This is a per-pair circuit breaker for failures that say something about the destination. Provider-side failures are excluded: a rate-limit or 5xx answer from Twilio or Telnyx, and a dropped connection or lost response on the Telnyx send, do not count toward the threshold, so a provider outage cannot disable SMS for a candidate whose number is fine. The WhatsApp template paths apply the same transient exemption, but still count an invalid-number result, since nothing else on those paths acts on one.
- From-number resolution. First outreach interviews are created without a company caller ID. When the interview is due, a pre-dispatch assignment step picks a live org number (skipping a job-tied line that has since been released) and stamps it onto the row. Phone dialing and web invites both wait for that stamp. If no healthy number is available, the interview stays pending and unassigned and is retried on the next assignment cycle — it does not fail or consume a retry. Once a number is stamped, later dispatch still requires a usable org line; if that later check fails, the call follows the failure-reschedule path described above.
- International calling restriction. Before PSTN/SIP dial, candidate outbound calls compare the calling codes of the selected
from_phone_numberand destinationto_phone_number. Same calling code is allowed. US/Canada (+1) ↔ Mexico (+52) is also allowed. Other mismatches are blocked by default and the call is cancelled withCancellationReason.INTERNATIONAL_CALL_RESTRICTEDwithout changing the candidate’s stage. Unparseable or invalid phone numbers fail closed (blocked), even when the support override is on. Support can enableinternational_calling_settings.allow_all_country_codes(org default or per-job override) via the Support Only settings surface. WhatsApp call invites are exempt (they are not PSTN dials). Email is unaffected — including first-touch email that may still send when a PSTN fallback dial is cancelled for country restriction. - Org international policy (YAML). Engineering can opt specific orgs into
server/international_policy/org_international_policy.yaml. When present: (1) from-number selection prefers a pool number matching the candidate’s calling code, else a number in the org’sprimary_country; (2) the dial gate additionally allows a dial when the selected from-number’s calling code matches that primary country (ORG_PRIMARY_COUNTRY); (3) whenallow_international_smsis false, cross-country PSTN SMS is blocked before the provider call (blocked_by_international_sms_policy) and first-touch outreach falls through to call + email (same permanent-block path as the per-candidate SMS error limit). Orgs absent from the YAML keep prior behavior (no SMS country gate; dials still restricted by the default country policy above). - ATS org-policy eligibility. For ATS-synced orgs, the dispatcher re-runs the ATS outreach eligibility check just before sending. If the ATS now classifies the candidate as ineligible, the call is cancelled and the candidate transitions directly into a blocking stage. The
OutreachBlockCategory.DO_NOT_CONTACTcategory now means “no consent on any channel” (set whennot has_any_consent) and maps toNO_CONSENTon first ingestion of the application orCONSENT_REVOKEDon a later re-check (the dispatcher’s just-before-send check is always a re-check, so it reachesCONSENT_REVOKED); theORG_OUTREACH_POLICYcategory (org-level reject/withdraw status, custom blocklists) maps toSKIP_OUTREACH. This is how org policy at the ATS reaches the dispatcher. Innova Bullhorn: that same pre-call Candidate GET already requests configured consent fields; Tenzo reconciles them onto the local candidate (same change-detection rules as ATS sync) before dialing so an opt-out that landed in Bullhorn after the last sync still blocks contact. - Concurrency cap. The dispatcher processes one or two outbound calls at a time per phone number — two by default, one during peak hours (5–10 AM Pacific). On orgs with a large backlog, a
PENDINGcall that comes due during a busy minute may slip a few seconds pastscheduled_datetime. The limit can be raised for a specific customer when needed, but there is no self-serve setting — it is adjusted by engineering, not in the product. - Stale-call cleanup. Long-pending calls on ATS-synced candidates are periodically checked against the ATS for fresh status. If the candidate has since been rejected or withdrawn in the ATS, the pending call is cancelled and the candidate moves to
DISPOSITIONED_IN_ATS.
Other Outreach Touches
The rest of this page is about the prospecting outbound path (call/SMS/companion email at the top of the funnel). A handful of other automated touches share the same gates but have their own schedulers, and a reader asking “why didn’t this email go out?” needs to know they exist:- Rejection email. Scheduled when a candidate is thumbs-downed or transitions to
DISPOSITIONED_IN_ATS(notRESUME_REJECTED— that sends the resume feedback email), after a configurable delay (rejection_email_delay_hours, default 48h, range 0–336h), then rounded forward to the next outreach-hours slot in the candidate’s timezone.enable_rejection_emailsdefaults to off at the org level — most orgs don’t send them unless they’ve opted in. Subject to email consent; the scheduled send is cancelled if the candidate transitions back toOUTREACH, and a send-time state guard skips it if the candidate is no longer thumbs-down / dispositioned when the delay elapses. Sent byprocess_scheduled_rejection_emails. A thumbs-down only schedules the email when Tenzo actually rejects the candidate in the ATS (linked ATS application + configured thumbs-down stage/disposition, or a successful ATS reject/stage-move). Returning toOUTREACHkeeps the schedule if the candidate is still thumbs-down; undoing thumbs-down also cancels a pending send. - Resume feedback email. Parallel flow to the rejection email but enabled by default. Same consent gate, same scheduler pattern (
process_scheduled_resume_feedback_emails). Gated byenable_resume_feedback_emails. Uses the samerejection_email_delay_hoursdelay. Send-time guard requires the candidate still beRESUME_REJECTED. - Scheduling reminders. Once a candidate has agreed to schedule a call but hasn’t picked a time, the system re-pings them on a configurable cadence (
process_scheduling_reminders). These pings honor outreach hours and per-channel consent the same way prospecting touches do. - Meeting reminders. Pre-interview SMS reminders (24h and 1h ahead of a scheduled call), sent by
process_scheduled_meeting_reminders. Consent-gated; not subject to outreach hours because the meeting time itself is what’s being honored. - Video interview reminder emails. Email reminders before a self-scheduled web call, governed by
VideoInterviewReminderSettings(defaults:reminder_email_count=7,reminder_email_hours=12.0). Distinct from the SMS meeting reminders above — these fire for video interviews only and use the email channel.
Inbound Call Routing
Outbound is only half the picture: candidates can dial the phone numbers Tenzo uses to reach them. Inbound calls arrive on LiveKit SIP trunks and are routed in this order (the legacy Twilio/inbound_call webhook now rejects any number not yet migrated to a trunk):
- Invalid/anonymous caller ID → reject.
- Permanent org phone number (DB-mapped to a campaign) → candidate-intake flow (collect name, and optionally email, at the top of the call).
- Legacy hardcoded mapping (Dollar Tree).
- Resumable incomplete call matched by from/to pair → resume the existing session via the State Snapshot Pattern.
- Known caller with prior call → duplicate the prior call, start a new interview.
- No prior-call phone match → verification flow. Creates a
VerificationCallrow and routes to a verification agent. Stage 1 identifies the caller, then it confirms their full name. After both validations, an incomplete interview normally continues in the same room; an already-completed interview gets a farewell, while unavailable continuity gets a safe retry-later notice, and both terminal routes close the room without starting screening. The stage-1 method is chosen byresolve_verification_method(org_id): whenorg_has_email_integration(org_id)is true the caller reads back an 8-character code (VerificationMethod.CODE); when it returns false (SMS-only orgs with no email integration) the caller is asked which number they applied with / were texted on, which is matched against the candidate on file (VerificationMethod.PHONE). The method is resolved from the dialed number’s org at routing time — LiveKit carries it in the room metadata — because the candidate (and campaign) isn’t known yet.
candidate_campaign_info.candidate_campaign_id[:8].upper() and surfaced to candidates via the auto-appended footer block in every outreach email rendered by send_virtual_interview_email (the email.footer.verification i18n string). It only applies to the CODE method: SMS-only orgs never send an email and have no code, so they use the PHONE method instead. Either way, candidates with no usable phone-on-file can still convert by dialing in and verifying.
On every successful verification, the verified caller’s number is written back
to the candidate row (best-effort, only when the field is empty) so subsequent
dispatch attempts pick up the candidate as phone-eligible. This happens on the
shared
handle_transition_confirmation path.Consent Revocation
When a candidate opts out, Tenzo revokes consent only on the channel(s) they were reachable on rather than flipping a single global flag. The candidate’s outreach then stops because there is no usable channel left, not because a separate suppression flag is set. State involved:- Per-channel consent columns on the candidate row —
sms_consent,email_consent,call_consent. These are the sole outreach-suppression mechanism:has_any_consent(candidate)is True if at least one is set. When all relevant channels are revoked,has_any_consentis False and no job can reach out. (Per-channel consent persistence is now unconditional — the oldPER_CHANNEL_CONSENT_DB_SYNCfeature flag is removed.) tenzo_stage = CONSENT_REVOKEDon each affectedCandidateCampaignInfo— the per-job reflection of the opt-out. Active + recoverable: re-consent on a channel, a recruiter override, or the candidate dialing in and completing a graded interview re-engages outreach (see Inbound re-engagement below).- Legacy: the
DO_NOT_CONTACTstage is retained as a read-only member (no writers, no stage actions) only because live rows still hold it. Those rows are functionally closed (outbound blocked; exits to Outreach, Dispositioned and Wrong Number still allowed) and drain on their own; the member is dropped once the count reaches zero. TheCandidate.do_not_contact/dnc_reasoncolumns no longer exist: “no consent on any channel” is derived from the consent columns wherever it is needed. Re-engagement never touches consent: consent returns through the channel the candidate used (inbound SMS →sms_consent, inbound email →email_consent, inbound call →call_consent), and that grant is awaited on the same inbound event before the revive runs, so a revive is never left half-applied.
ConsentService.apply (in consent/consent_service.py) with a ConsentTrigger; the trigger’s row in CONSENT_POLICY (consent/consent_policy.py) names the channels revoked and the ATS fields written back, and a closing row transitions the candidate-campaign to CONSENT_REVOKED. Two revocation channel groups, recorded in the stage-history reason as RevocationChannel:
RevocationChannel.TEXT_AND_CALL— revokessms_consent+call_consent(keeps email). Every SMS- and call-arriving opt-out trigger uses this; the old “SMS-only, keep AI” path is collapsed.RevocationChannel.EMAIL— revokesemail_consentonly (keeps SMS + call consent). Used by theEMAIL_REPLYtrigger.
NOT_INTERESTED vs CONSENT_REVOKED:
How candidates end up in CONSENT_REVOKED
-
Inbound SMS, candidate asks to be removed. During an SMS conversation, the LLM classifies the candidate’s intent. An emphatic, global opt-out (“don’t ever text me again”, “remove me from your system”, or clear anger at being contacted) is classified as
REQUESTED_NO_CONTACT. Rather than revoking straight away,transition_sms_state_actionfirst sends a confirmation message asking whether they’d still like to hear about future opportunities, and moves the SMS conversation state to the intermediateAWAITING_DNC_CONFIRMATIONstate. The final state depends on their reply:- A “no” to future contact calls
execute_revoke_channel_consentwithConsentTrigger.SMS_CLASSIFIED_OPT_OUT, which runsConsentService.apply: revokes text + call consent and transitions toCONSENT_REVOKED. The stage transition’s post-commit action suspends the SMS conversation (SUSPENDED) so the texter stops and a later inbound reaches the re-engagement lane. - A “yes” calls
execute_confirm_campaign_opt_out, which resolves toNOT_INTERESTED(this job only, consent intact). - An ambiguous or off-topic reply defaults to the opt-out, erring on the side of respecting their privacy.
NOT_INTERESTED, the failure is logged, and the conversation is suspended so the candidate is not left waiting on a question they never received. A delivery failure is never read as a consent revocation. A message that reads as a this-job-only decline is classifiedNOT_INTERESTEDfrom the start and never enters this confirmation flow. - A “no” to future contact calls
-
SMS
STOPkeyword. A keyword-level STOP from the carrier (Twilio’s built-in unsubscribe handling, or the explicit STOP-detection path) revokesTEXT_AND_CALLconsent with sourceSMS_STOP. This is independent of LLM classification. -
Email reply. An email opt-out revokes
RevocationChannel.EMAILonly (sourceEMAIL_REPLY), leaving SMS + call consent intact. A later inbound email from the same candidate re-grantsemail_consent(sourceinbound_email) before the reply is routed, and the reply is run through the re-engagement classifier: only a clear ask to be contacted again revives the application (see Inbound re-engagement below). -
Call-side emphatic opt-out. When a candidate asks not to be contacted during an AI phone screen, Tenzo revokes
TEXT_AND_CALLconsent and transitions toCONSENT_REVOKED. Two paths reach this, distinguished by the revocation source: the live agent recognizes the request mid-call, ends the call with a no-contact confirmation (scoped to calls and texts — email consent is untouched), and the post-call processor revokes with sourceCALL_IN_CALL_OPT_OUT; if the live agent missed it, the post-call transcript detector revokes with sourceCALL_CLASSIFIED_OPT_OUT. Unlike the SMS emphatic path, there is noAWAITING_DNC_CONFIRMATIONround-trip — the call path revokes consent immediately with no confirmation step. -
ATS sync / excluded status. Excluded-status candidates do not revoke consent — they park in
SKIP_OUTREACHper-application. Recovery toOUTREACHrequires the blocking condition to clear and a passing full org outreach eligibility re-check (owner, placement, consent, and other policy rules), not just the ATS status change:- JobDiva — candidates with status “Unavailable Indefinitely” transition their active applications to
SKIP_OUTREACHnightly, and recover via theSKIP_OUTREACH → OUTREACHtransition when they become available again — but only when the latest stage history shows the park came fromjobdiva_unavailable(same history-trigger gate as Bullhorn excluded-status recovery). - Bullhorn — excluded-status candidates likewise park in
SKIP_OUTREACH(generalized to any Bullhorn org). When the excluded status clears on sync, Tenzo re-runscandidate_outreach_eligibility()and only recovers applications that pass and whose latest stage history is aSKIP_OUTREACHpark with triggerats_excluded_status. Parks from other triggers (for examplepre_call_validationwhen the application is not in a valid calling stage) stay inSKIP_OUTREACHso ATS sync does not undo them and create a recovery loop. Org policy blocks (for example owner or placement rules) also leave the application inSKIP_OUTREACH. The Bullhorn-sync reconcile hook separately moves apps toCONSENT_REVOKEDwhen consent is revoked upstream — both active applications and applications parked inSKIP_OUTREACHfromats_excluded_status(so a later excluded-status recovery cannot un-park intoOUTREACHpast the consent revocation). - Outreach eligibility mapping —
OutreachBlockCategory.DO_NOT_CONTACTnow means “no consent on any channel” (set whennot has_any_consent) and maps toNO_CONSENTon first ingestion of the application orCONSENT_REVOKEDon a later re-check (theis_first_ingestionflag threaded from the enrollment/ingestion call sites selects which);OutreachBlockCategory.ORG_OUTREACH_POLICYmaps toSKIP_OUTREACH.
- JobDiva — candidates with status “Unavailable Indefinitely” transition their active applications to
Inbound SMS routing
Every inbound text on a resolved application is routed by one pure decision (texter/inbound_routing_policy.py) over a snapshot of what Tenzo already knows. The rules run in order and the first match wins:
- A dev command (non-production
CLEAR HISTORY). - A live intake or interview call owns the SMS (answered before the application is resolved).
- The conversation is mid opt-in confirmation: the opt-in classifier owns the reply, a literal
STOPincluded. - A carrier
STOPkeyword:ConsentService.apply(SMS_STOP), no call required. - No SMS consent: the message is dropped. This is the only rule that refuses to reply for lack of consent; the inbound consent grant is awaited before the decision, so it only fires when that grant did not apply.
- The candidate is dispositioned in the ATS, no other lane is mid-question with them, and the number reaches no other open application: the canned “contact the recruiting team” notice.
- The application is closed to the AI (
NOT_INTERESTED,CONSENT_REVOKED, legacyDO_NOT_CONTACT, orHUMAN_REVIEW_REQUESTED) and no decline question is pending: the re-engagement classifier. A clear “I want back in” revives the application toOUTREACH; anything else is dropped, or on a human-owned thread left to the recruiter. - The job is paused: the paused notice, or the follow-up / post-interview Q&A lanes for candidates who already have a booked interview or a graded one.
- A video-interview link is out: the web-call Q&A lane (finished web calls fall through to the post-call lanes).
- The not-interested flow asked a question (
NOT_INTERESTED_AWAITING_REASON,AWAITING_DNC_CONFIRMATION): its handler reads the answer. - The post-interview feedback survey is open: the feedback handler.
- Everything else is a normal dialog turn, routed pre/post-call on the durable conversation state.
sms_consent, written only by ConsentService) decides whether we may reply; the stage (tenzo_stage, written only by the stage orchestrator) decides what we say; the conversation state (sms_conversation_state, written only through the SMS rules table) picks the dialog lane a normal reply lands in. SUSPENDED is the single suspension marker and is a projection of the stage: every stage that closes the conversation to the AI writes it (whichever channel the opt-out arrived on, so a web-form or email opt-out suspends the text conversation the same as a STOP), OUTREACH and OUTREACH_PAUSED clear it, and nothing routes on it. Why a conversation is suspended is answered by the stage. Email replies apply the same consent-then-stage test before picking a lane, and share the stage half of it (stage_gate, in the same module) with the SMS router. Email awaits its own inbound email_consent grant first (skipped while the not-interested flow has a question pending, since that reply may be the “don’t contact me” answer), then checks its own channel consent. An application closed to the AI (NOT_INTERESTED, CONSENT_REVOKED, DO_NOT_CONTACT, or the HUMAN_REVIEW_REQUESTED hold) with no decline question pending goes to the same channel-neutral re-engagement classifier and handler as SMS rules 7 and 10; an open decline question goes to the not-interested flow. A SUSPENDED conversation row on an open stage is an invariant breach: the email is dropped and the row is left as-is (the SMS router repairs it; email does not).
Inbound re-engagement from an opt-out stage
A candidate in a terminal opt-out stage (NOT_INTERESTED, CONSENT_REVOKED, or DO_NOT_CONTACT) who calls in and completes a graded interview is automatically re-engaged — they don’t need a recruiter override. This mirrors the SMS opt-back-in flow:
- A call the candidate places themselves (an inbound call, including a verified caller who is eligible to continue into an interview) is evaluated on its own merits rather than inheriting the candidate’s existing opt-out. A candidate who calls in and declines again is kept opted out.
- When the inbound call produces a real interview outcome (a completed interview, or one that fails a required knockout question), the candidate is re-engaged: the per-job stage moves back to
OUTREACH. Consent is re-granted per-channel by the inbound hard rules — an inbound call grantscall_consent(viamaybe_grant_call_consent_on_inbound_call); it does not touchsms_consent, so the candidate stays text-suppressed until they text in. Eligibility uses the same stage-transition rule as SMS re-engagement, which already permits all three opt-out stages. - Re-engagement happens before the interview’s own stage move (to
CALL_COMPLETEDorFAILED_KNOCKOUT), so that move starts from the freshOUTREACHstage. Any follow-up call that re-engaging would otherwise queue is cleaned up by that same stage move, so no stray redial is left behind. Candidates who never call in remain fully suppressed. - A candidate who resubmits the web interview form without the AI opt-out box is re-engaged the same way, regardless of which channel opted them out (web form, SMS, phone, ATS sync, or admin): call consent is re-granted where a channel opt-out (SMS, phone, ATS sync, admin) had removed it —
HUMAN_REVIEW_REQUESTEDitself never changes consent, so for a web-form opt-out the grant is a no-op — and the per-job stage moves back toOUTREACHfrom any candidate-closed stage (NOT_INTERESTED,CONSENT_REVOKED,DO_NOT_CONTACT, or theHUMAN_REVIEW_REQUESTEDhold). Recruiter and system decisions (resume or fraud rejection, an ATS park, a cancelled application) are never overridden by a form submit. The one hold is an SMS opt-out: if the candidate’s SMS consent is revoked and this or any other application was closed by a consent revocation, the form does not revive it, because reviving would restart texting. An ATS do-not-contact that is reversed this way is re-applied by the next ATS sync if the ATS still says so. - A candidate who texts in from
NOT_INTERESTED,CONSENT_REVOKED,DO_NOT_CONTACTor theHUMAN_REVIEW_REQUESTEDhold is run through the re-engagement classifier (rule 7 above). A clear “I want back in” revives the application toOUTREACH(the inbound already grantedsms_consent), which restarts the text conversation fromINITIAL_OUTREACH; on a human-owned thread anything else is left to the recruiter. - A candidate who emails in from the same closed stages (
NOT_INTERESTED,CONSENT_REVOKED,DO_NOT_CONTACTor theHUMAN_REVIEW_REQUESTEDhold) with no decline question pending is run through the same channel-neutral re-engagement classifier and handler, judged on the latest email only rather than the whole thread. A clear “I want back in” revives the application toOUTREACH(the inbound already grantedemail_consent) and sends a confirmation email (“Welcome back! We’ll start reaching out about this job again.”, with no STOP clause); anything else is dropped, or on a human-owned thread left to the recruiter. Two consequences worth knowing: an opted-out candidate who emails “please stop” getsemail_consentrestored, but the classifier defaults to ignore, the stage stays closed, and every outbound email is gated by consent plus stage, so nothing is sent; and an email-driven revive restarts the SMS conversation projection atINITIAL_OUTREACHeven whilesms_consentis false — an accepted side effect, since SMS sends stay blocked by the consent gate.
Revoking consent does not retroactively rewrite every existing
CandidateCampaignInfo to CONSENT_REVOKED. Other jobs reflect the revoked
channel when their next dispatch re-runs eligibility (or, for ATS-driven
flips, when add-to-campaign next evaluates the candidate). Outreach is
suppressed everywhere immediately because every send gate reads the
per-channel consent columns directly.Related
- Candidate Consent — full consent matrix and channel fallback rules
- Candidate Stage Lifecycle — stage transitions and what each stage means
- Cooldown across jobs — cross-job cooldown behavior, expiration