Skip to main content

Documentation Index

Fetch the complete documentation index at: https://f4c7a9e2d8b1-docs.tenzo.ai/llms.txt

Use this file to discover all available pages before exploring further.

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 to OUTREACH. Two paths get them there:
  1. Resume passesAWAITING_RESUME_REVIEW → OUTREACH after the resume scorer accepts the candidate against the job’s requirements.
  2. Recruiter overrideRESUME_REJECTED → OUTREACH when a recruiter manually clicks Progress to Interview. Any scheduled rejection email is cancelled at the same time.
On entry to OUTREACH, a PENDING call row is created (idempotently — if one already exists, nothing happens). 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 first scheduled_datetime is computed from the org’s Outreach Hours settings and the candidate’s timezone.
  • Timezone: candidate.timezone on the candidate row. The scheduling code itself does no inference at outreach time — it reads the field as-is and falls back to US/Eastern if empty. The field is populated at candidate intake in this order: (1) explicit value from the ATS/import, (2) zip-code geocoding, (3) phone area-code lookup. If all three fail at intake, the candidate stays untimezoned and is treated as Eastern at outreach time.
  • Weekday window: Configurable per org (default 08:00–21:00 candidate-local).
  • Weekend window: Configurable per org. call_on_weekends is on by default and weekend hours default to the same window as weekdays. If call_on_weekends is off, weekend slots roll forward to Monday.
If the current time falls inside the window, the call is scheduled for now. If not, it rolls forward to the next valid slot.

Initial Outreach Method

Each job has an initial_outreach_method setting that biases the first contact:
SettingBehavior
OUTREACH_HOURS (default)First outreach only (is_first_outreach, gated by sms_status == NONE): inside outreach hours → call; outside → SMS + companion email. Lets late-evening adds get a same-day touch. Retries always wait for the next outreach window; SMS is never re-fired outside hours.
CALLAlways place a call (subject to consent and line-type rules).
SMSSend an SMS as the first touch; a companion email is sent alongside.
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 whose Campaign.call_types.applicant_call_type (or sourced_call_type) is CallType.WEB, every PENDING Call row is created with web_call=True. The dispatcher short-circuits the normal phone path at the top of make_initiate_call_request: it calls invite_to_web_call instead, 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. The retry budget (retry_count, retry_hours) and outreach hours still apply — each retry resends a fresh invite email instead of dialing. Link expiration is governed by VideoInterviewLinkExpirationSettings (default: no expiration). When the candidate clicks the link and schedules, the stage transitions to AI_INTERVIEW_SCHEDULED and the retry loop stops.

Channel Selection at Send Time

When the dispatcher picks up a PENDING call, it re-evaluates channel right before sending:
PENDING call due

    ├─ Stage blocks outreach? ────────────────────────── CANCEL (see "What Stops Outreach")
    ├─ Master AI-communications consent off? ─────────── try email-only or cancel
    ├─ Cooldown on another job? ──────────────────────── move to COOLING_DOWN

    ├─ initial_outreach_method = SMS?
    │   └─ phone line is sms-blocked (landline/VoIP/etc.)?
    │       ├─ yes → call + email fallback
    │       └─ no  → send SMS (+ companion email)

    ├─ initial_outreach_method = OUTREACH_HOURS?
    │   └─ inside business hours? ─── yes → call
    │       outside business hours? ── send SMS (+ email)  (line-type gate same as above)

    └─ initial_outreach_method = CALL
        └─ place call (or fall back per consent — see Candidate Consent)
Line-type gate. SMS is blocked when the candidate’s stored phone line type is LANDLINE, PAGER, VOICEMAIL, PREMIUM, SHARED_COST, FIXED_VOIP, or NON_FIXED_VOIP. 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:
  • all_ai_communications_consent=False blocks call and SMS entirely. Email is not gated by this flag — email proceeds based on email_consent alone.
  • sms_consent=False blocks SMS; the system falls back to email when possible.
  • call_consent=False blocks calls.
  • email_consent=False blocks email (no further fallback).

Retries and Cooldowns

Retry Budget

When a call ends without a conversation (voicemail, no-answer, busy), a new PENDING call is scheduled. The behavior comes from the job’s Call Retry Settings:
SettingDefaultMeaning
reschedule_on_voicemailTrueMaster switch for voicemail retries and consent-gated retries. Turning it off disables both.
retry_count5Maximum retry attempts after the first call.
retry_hours26.0Spacing between retries (hours).
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. 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 at retry_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 (Retell 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’s CandidateCampaignInfo 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. Cooldown days are configurable per org by candidate source and call type:
SourceChannelDefault
SourcedPhone1 day
SourcedWeb1 day
ApplicantPhone1 day
ApplicantWeb0 (disabled)
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 marked NOT_INTERESTED and no new outreach is scheduled:
  • NOT_INTERESTED — candidate declined this job; can be re-engaged for other jobs.
  • DO_NOT_CONTACT — candidate is on the org’s DNC list; blocks outreach on every job. See Do Not Contact below.
  • FRAUD_REJECTED
  • DISPOSITIONED_IN_ATS (the ATS marked them rejected/withdrawn)
  • USER_CANCELED (recruiter explicitly stopped outreach)
  • FAILED_KNOCKOUT
  • HUMAN_REVIEW_REQUESTED
  • AI_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 (the dispatcher hit a gate that should prevent contact but isn’t a permanent terminal — e.g. an ATS-eligibility soft-block)
  • COOLING_DOWN
INVALID_CONTACT is functionally a stop too, but it doesn’t sit in the blocking-stages list — it cancels only the call that surfaced the problem and is documented under Invalid contact and other dispatch gates below.
  • all_ai_communications_consent=False → no calls, no SMS. Email is independent — it still goes through if email_consent=True and an email integration is connected.
  • sms_consent, call_consent, and email_consent are per-channel master gates. With all three false, outreach is cancelled entirely.

Retry exhaustion

When retry_count is reached without a successful conversation, the candidate exits OUTREACH and no more calls are scheduled.

Invalid contact and other dispatch gates

Beyond stage and consent, the dispatcher applies several operational gates at the moment a PENDING 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. Every dispatcher query filters out calls whose campaign status is PAUSED. While a job is paused, no candidate on that job is contacted, regardless of their scheduled time. Calls resume when the job is unpaused; their existing scheduled_datetime is honored.
  • 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 AI-comms-opt-out branch’s NO_AI_COMMUNICATIONS_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, or get_no_answer_email_task no-op’d), the email-only branch is skipped/falls through — the downstream no-phone gate in make_initiate_call_request then transitions the candidate to INVALID_CONTACT. Web-call jobs (Call.web_call=True) are excluded from this branch — invite_to_web_call handles them with a webCallEmail invite 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 transient provider issues.
  • From-number resolution. A candidate’s call must be placed from a phone number registered to the org. If no usable from-number is available at dispatch time (no registered numbers, all numbers exhausted), the call fails and is rescheduled via the failure-reschedule path described 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 (org-level reject/withdraw status, ATS-side DNC list, custom blocklists), the call is cancelled and the candidate transitions directly into a blocking stage — typically DO_NOT_CONTACT or SKIP_OUTREACH, depending on the reason. This is separate from the candidate-level do_not_contact flag; it’s how org policy at the ATS reaches the dispatcher.
  • Concurrency cap. The dispatcher processes one outbound call at a time (MAX_CONCURRENT_CALLS = 1). On orgs with a large backlog, this means a PENDING call that comes due during a busy minute may slip a few seconds past scheduled_datetime. It is not user-tunable.
  • 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 24 hours after the candidate enters RESUME_REJECTED, then rounded forward to the next outreach-hours slot in the candidate’s timezone. enable_rejection_emails defaults to off at the org level — most orgs don’t send them unless they’ve opted in. Subject to email consent; if the candidate transitions back to OUTREACH before the email fires, the scheduled send is cancelled. Sent by process_scheduled_rejection_emails.
  • Resume feedback email. Parallel flow to the rejection email but enabled by default. Same consent gate, same scheduler pattern (process_scheduled_resume_feedback_emails).
  • 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=2, reminder_email_hours=24.0). Distinct from the SMS meeting reminders above — these fire for video interviews only and use the email channel.
The cadence settings for the prospecting path (retry count/hours, cooldown days, outreach hours, initial outreach method) are defined at the org level and can be overridden per job. Job-level settings, when present, replace the org default for that job only — there’s no merge.

Inbound Call Routing

Outbound is only half the picture: candidates can dial the phone numbers Tenzo uses to reach them. The Twilio /inbound_call webhook routes incoming calls in this order:
  1. Invalid/anonymous caller ID → reject.
  2. Permanent org phone number (DB-mapped to a campaign) → candidate-intake flow (collect name/email at the top of the call).
  3. Legacy hardcoded mapping (Dollar Tree).
  4. Resumable incomplete call matched by from/to pair → resume the existing session via the State Snapshot Pattern.
  5. Known caller with prior call → duplicate the prior call, start a new interview.
  6. No prior-call phone match → verification flow. Creates a VerificationCall row and routes to a websocket that asks the caller for an 8-character code, then their full name. On both validations, the inbound call is linked to the candidate row and transitions into the interview.
The verification code is computed from 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). Candidates with no usable phone-on-file can still convert by dialing in and reading back the code.
The inbound verification flow does not write the verified caller’s number back to the candidate row by default. The phoneless email-only outreach work in this branch adds that write (best-effort, only when the candidate’s field is empty) so subsequent dispatch attempts pick up the candidate as phone-eligible.

Do Not Contact

DNC is org-wide, not per-job. It’s implemented as two coupled pieces of state:
  • Candidate.do_not_contact — boolean flag on the candidate row. Once true, no job at the org will reach out.
  • Candidate.dnc_reason — why the flag was set: CANDIDATE_REQUESTED, SMS_STOP, ATS_EXCLUDED_STATUS, or LEGACY_UNKNOWN.
  • tenzo_stage = DO_NOT_CONTACT on each CandidateCampaignInfo — the per-job reflection of the flag. Only legal exit transition is to DISPOSITIONED_IN_ATS, so this stage is effectively terminal.
NOT_INTERESTED vs DO_NOT_CONTACT:
NOT_INTERESTEDDO_NOT_CONTACT
ScopeThis job onlyAll jobs at the org
SourceCandidate declined this roleCandidate asked to be removed entirely, or ATS marked them so
Re-engageableYes — recruiter can override or another job can re-contactNo — flag persists; the org-wide DNC list does not auto-clear
Legal exitsDISPOSITIONED_IN_ATS, SKIP_OUTREACH, USER_CANCELEDDISPOSITIONED_IN_ATS

How candidates end up on DNC

  1. Inbound SMS — candidate asks to be removed. During an SMS conversation, the LLM classifies the candidate’s intent. If they express a global opt-out (“don’t ever text me again”, “remove me from your system”), the SMS not-interested tool calls execute_confirm_global_dnc, which sets do_not_contact=True with dnc_reason=CANDIDATE_REQUESTED and moves the SMS conversation state to DO_NOT_CONTACT. If the message reads as a this-job-only decline, the tool calls execute_confirm_campaign_opt_out instead — that’s NOT_INTERESTED, not DNC.
  2. SMS STOP keyword. A keyword-level STOP from the carrier (Twilio’s built-in unsubscribe handling, or the explicit STOP-detection path) sets the flag with dnc_reason=SMS_STOP. This is independent of LLM classification.
  3. Call result. If the candidate tells Morgan on a call to take them off the list, the same execute_confirm_global_dnc path runs from the post-call processor (via the not-interested action), with dnc_reason=CANDIDATE_REQUESTED.
  4. ATS sync (provider-specific). Some ATSes have a “do not contact” or equivalent status that Tenzo mirrors:
    • JobDiva — candidates with status “Unavailable Indefinitely” are pulled into the DNC list nightly with dnc_reason=ATS_EXCLUDED_STATUS. If their status changes back later, this reason can be cleared.
    • Outreach eligibility mapping — any ATS provider whose eligibility check returns OutreachBlockCategory.DO_NOT_CONTACT triggers a transition to the DO_NOT_CONTACT stage at the time the candidate is added to a job.
  5. Defense-in-depth at dispatch. Independent of how the flag was set, the call dispatcher re-checks candidate.do_not_contact immediately before placing each call. If true, every PENDING call on that candidate is cancelled with CancellationReason.DNC_MATCH and the candidate’s stage on the current job transitions to DO_NOT_CONTACT. This guards against a stale PENDING row that was scheduled before the DNC flag flipped.
Setting do_not_contact=True does not retroactively rewrite every existing CandidateCampaignInfo to DO_NOT_CONTACT. Other jobs flip when their next dispatch happens (or, for ATS-driven flips, when add-to-campaign next evaluates the candidate). Inbound SMS routing is short-circuited immediately because it reads the flag directly.

Reasons that can be cleared vs reasons that stick

  • CANDIDATE_REQUESTED and SMS_STOP are treated as the candidate’s explicit, persistent choice. ATS sync will not overwrite the flag to False when these reasons are present.
  • ATS_EXCLUDED_STATUS can be cleared if the ATS status moves out of the excluded state.
  • LEGACY_UNKNOWN exists for rows migrated from an earlier system without an explicit reason.