Architecture
July 11, 2026 · View on GitHub
Detailed module documentation for OpenOutreach. See CLAUDE.md for rules and quick reference.
OpenOutreach is a browserless, email-first AI sales agent: it learns a campaign's ICP and runs the whole funnel — define ICP → discover → qualify → rank → find email → agentic email — off licensed data, with no LinkedIn account and no scraping.
Project Layout
All source lives in the single openoutreach/ package; Django apps are nested inside it
(dotted AppConfig.name, short labels). One engine, one outreach channel:
manage.py
tests/
openoutreach/
settings.py # Django settings (SQLite at data/db.sqlite3)
urls.py
discovery.py # Lead Finder client (ICP search + row embedding) — the top of the funnel
core/ # engine app (label: core) — daemon, task queue + scheduler,
# Campaign/SiteConfig/Task models, llm.py, conf.py, onboarding,
# ML (qualifier/embeddings/kit), discovery+qualify pipeline,
# the two agents, db/ helpers, session, geo, management commands,
# vendored mem0
emails/ # channel app (label: emails) — enrichment (BetterContact), Mailbox +
# import + SMTP/IMAP, sender/inbox, the three task handlers
crm/ # app (label: crm) — Lead, Deal
chat/ # app (label: chat) — ChatMessage (the per-Deal conversation)
legacy/ # model-less app (label: legacy) — migration-history anchor only
contacts/ # central contacts-store client (service.py only — no models, not an app)
Layering: core owns orchestration, the ML/discovery/qualify pipeline, and the
channel-agnostic models; the emails app owns the enrichment + send/read mechanics and the
task handlers. core imports channel code only at wiring points (the daemon's handler map).
No LinkedIn. The browser, Voyager API, connect/check_pending, and the linkedin_cli
dependency were removed in the email-first pivot. The legacy app is intentionally
model-less — it exists only to anchor migration history that core/crm depend on so
existing installs stay on a forward-only, backward-compatible migration graph (the retired
LinkedInProfile/SearchKeyword/ActionLog models were deleted in legacy/0012).
Entry Flow
manage.py — stock Django management entrypoint. Bare python manage.py (no subcommand, or a
leading flag) defaults to rundaemon.
rundaemon management command (management/commands/rundaemon.py)
Startup sequence:
- Configure logging — level from
--verbosity, banner, noisy third-party loggers silenced (core/logging.py). - Ensure DB —
migrate --no-input(the custom migrate; see below) +setup_crm(idempotent). - Onboard — if
missing_keys()is non-empty: interactive wizard on a TTY, else print what's missing and exit (no TTY, no silent partial start). - Create session — validate
llm_api_key, resolve the active operatorUser, build anOperatorSession, default its campaign to the first one. - Run —
run_daemon(session).
Docker's start script execs python manage.py rundaemon (no Xvfb/VNC — there is no browser).
Other management commands
migrate— overridden (management/commands/migrate.py+core/migration_compat.py): before Django's migration-consistency check runs, it relabels anylinkedinrows indjango_migrationstolegacy, so a pre-pivot DB upgrades with a plainmigrate(no manual SQL, no--fake). Idempotent no-op on fresh installs.setup_crm— idempotent CRM bootstrap (default Site).reset_data— wipe pipeline data for a fresh run.
Onboarding (core/onboarding.py)
Email-first, built as an ordered list of idempotent steps (STEPS). Each Step is a
(key, is_done, run) triple: is_done() reads the DB (never prompts), run() collects what's
missing and persists it the moment it succeeds. onboard_interactive() runs only the steps
whose is_done() is false, in order — so a partial onboarding resumes exactly where it stopped and
a satisfied step is never revisited. There is no end-of-wizard apply() that could half-fail; each
step is its own commit point.
campaign product description + target + booking link → Campaign row
llm LLM creds, live-verified via verify_llm_credentials (retries in place on failure)
mailbox field-by-field SMTP box → auth-check → Mailbox row; retries with values retained
bettercontact API key (mandatory — the SAME key powers Lead Finder discovery AND enrichment)
account your email (BCC + newsletter target) → country → newsletter (opt-in) → legal (required gate) → operator User + subscribe
- Cancellation is a single exception: prompts return
Noneon Ctrl+C,_required()turns that intoOnboardingCancelledat one boundary, and the mailbox step catches it (cancel with a box already connected just stops adding more; cancel with none aborts). - A failed step re-asks its own fields (mailbox retries retain what you typed; LLM retries re-verify) — it never rewinds to an earlier step or restarts the wizard. This is what fixed the "SMTP onboarding keeps looping back" bug, together with
emails/smtp.verify_authnow selecting the transport by port (implicit SSL on 465, STARTTLS on 587) instead of hard-codingstarttls(). - The operator's email is asked (the
accountstep) and stored asUser.email— it is the human's own inbox, deliberately distinct from the mailboxfrom_address(the sending robot). It is where the daemon BCCs a copy of every send (send.py/follow_up.pyreadsession.django_user.email), the newsletter subscribes, and the contacts give-back keys the operator. TheFrom:header stays the mailboxfrom_address.account'sis_done()requires an active staffUserwith a non-blank email, so a blank-email account can't short-circuit the address prompt. missing_keys()returns the keys of unsatisfied steps (campaign/llm/mailbox/bettercontact/account), so the daemon knows onboarding is incomplete until every gate passes.- The newsletter opt-in default is jurisdiction-aware (off in GDPR/opt-in countries via
core/geo.is_gdpr_protected), but an explicit yes always subscribes (lawful consent anywhere). Nothing is persisted in theaccountstep until the Legal Notice is accepted. - The interactive wizard is vendored in
onboarding_wizard.py: thintext/integer/confirm/multilinefunctions over questionary/prompt_toolkit, each owning its own validation loop and returning a value orNone(cancel). No externalopenoutreachpackage dependency.
Deal State Machine
crm/models/deal.py:DealState (OpenOutreach-owned TextChoices) is the whole funnel — a lead
is discovered and qualified without an email in hand (Lead Finder returns firmographics, not
addresses), so the funnel first finds the email and then talks:
QUALIFIED ─(GP rank gate)─▶ READY_TO_FIND_EMAIL ─(find_email/submit)─▶ FINDING_EMAIL ─(collect_email/poll)─▶ hit: READY_TO_EMAIL
discovered + qualified ranked, awaiting the provider job in flight; miss: FAILED (reason="no email")
(no email yet) paid lookup request_id in task payload │
(free hub hit → READY_TO_EMAIL directly, no job) ▼
READY_TO_EMAIL ──(email opener)──▶ EMAILED ⟲ (agentic follow-up) ──▶ COMPLETED / FAILED
read replies (IMAP) → agent: send / wait / complete
send: threaded SMTP reply, re-arm next_follow_up_at
READY_TO_FIND_EMAIL— passed the GP confidence gate (ready_pool.promote_to_readyabovemin_gp_confidence); queued for the paid lookup (one credit per verified hit). The gate rations spend to leads the model is confident about; the submit leg additionally fires only when there's mailbox send-headroom for the result today.FINDING_EMAIL— a provider job is in flight; the deal is excluded from the candidate pool (so the next submit slot can't re-select it and double-charge) whilecollect_emailpolls to termination. The job handle + poll backoff live in the collect task's payload, never on the deal, so an in-flight lookup rides on the persisted task row and survives a restart.READY_TO_EMAIL— an address exists; queued for the opener. A cheap, ungated FIFO send-queue paced only by the per-box daily cap (no ranking step).EMAILED— the opener has been sent; the agentic follow-up loop reads IMAP replies and decides send/wait/complete, paced by the agent's ownfollow_up_hours(stamped onDeal.next_follow_up_at), until a terminalCOMPLETED/FAILED.
The paid lookup is a two-leg async handshake (mirroring the retired connect→check_pending). find_email (submit) resolves free-hub-first (hit → READY_TO_EMAIL with no job/credit), else fires a provider job and parks at FINDING_EMAIL; a couldn't-submit (no key / API down) stays READY_TO_FIND_EMAIL. collect_email (poll) is then tri-state: hit → READY_TO_EMAIL (address given back to the hub); miss (job terminated, no address) → FAILED, reason="no email", outcome blank — critically not wrong_fit, because the ML labeler reads FAILED+wrong_fit as a negative and skips every other FAILED deal, so a lead we simply couldn't find is ML-skipped, never scored a bad fit; still running → chains the next poll with doubled backoff, or past the deadline reverts FINDING_EMAIL → READY_TO_FIND_EMAIL for a fresh submit (no credit spent).
crm/models/deal.py:Outcome (TextChoices): converted, not_interested, wrong_fit, no_budget,
has_solution, bad_timing, unresponsive, unknown — on Deal.outcome. Lead.disqualified=True =
permanent account-level exclusion (never given a new deal). LLM qualification rejections =
FAILED deals with wrong_fit outcome (campaign-scoped). Pre-Deal Lead states are implicit:
url-only (a Lead row with a null embedding) vs embedded (has an embedding + profile_text,
awaiting qualification).
(The LinkedIn connect leg — READY_TO_CONNECT/PENDING/CONNECTED, the connect/check_pending
retry+backoff columns — was removed with the channel. Existing deals stranded at those states are
remapped to QUALIFIED on upgrade so they re-enter the email funnel.)
Task Queue
Persistent queue backed by the Task model. Worker loop in core/daemon.py:
seconds_until_active() guard pauses outside the daily active-hours window (single contiguous
window, no weekend skip) → claim_next (opportunity-cost order, see TaskQuerySet.pending) →
set campaign on session → RUNNING → dispatch via _HANDLERS → COMPLETED/FAILED. A ModelHTTPError
from the LLM stops the daemon with a clear config hint; any other exception fails just that task and
continues. Between tasks a _HumanRhythmBreak injects random burst/break pauses, and a Heartbeat
logs an alive — … line so the daemon never goes silent for more than 5 minutes. reconcile(session)
runs once before the loop and whenever nothing is due, recovering crash-stale RUNNING tasks and
topping up the drains.
Priority vs scheduling are separate. claim_next picks the highest-value due task —
follow_up (a live reply waiting) > collect_email (a cheap poll that unblocks a deal) > email
(a cold opener) > find_email (new paid speculative work). seconds_to_next sleeps by earliest
scheduled_at alone (never by priority), so a find_email due in 1m never oversleeps behind a
follow_up due in 6h.
Rows come in two shapes; both are created only in core/scheduler.py (no other module inserts
Task rows):
- Lazy drains (
find_email/email/follow_up) —payload = {"campaign_id": <id>}only; the handler resolves a concrete target at run time via one eligibility query. Minted byflush_*_queuewhen there's eligible work under the day's send cap; no pre-materialized schedule. - Bound poll (
collect_email) —payloadcarries the in-flight lookup'sdeal_id,provider,request_id,submitted_at, and backoffattempt.schedule_collect_emailmints it; it is self-chaining (each still-running poll mints its successor), so one live poll exists per lookup — bypassing the drains' single-slot guard by construction.
There is no spend cap and no Poisson pacing. Paid find_email spend rides on send capacity:
flush_find_email_queue— mints one submit slot when there's mailbox send-headroom for the result today:Mailbox.objects.remaining_today()minus everything already in the send pipeline (READY_TO_EMAIL + FINDING_EMAIL). One slot per call (the handler is the pipeline pump, so a batch would fan out discovery). No-op unless a mailbox is connected and the finder is configured. The GP gate rations which leads qualify; the send cap bounds how many lookups ride the pipeline — so we never resolve an email we couldn't send today, and free misses re-open the gate at no cost.- Eager drains (email legs — no anti-bot rhythm to fake) —
flush_email_queueemits an immediate slot for everyREADY_TO_EMAILdeal;flush_follow_up_queueone for every dueEMAILEDdeal. Both capped by pool-wide per-box headroom, no-op while a PENDING task of their type exists. reconcile(session)— recovers stale RUNNING tasks, then per campaign runs all three drains. Boundcollect_emailpolls are self-chaining and not reconciled. Called on startup and whenever the queue has no due task.
Handlers (in emails/tasks/, signature handle_*(task, session, qualifiers)):
handle_find_email(tasks/find_email.py) — the submit leg. Drives the discovery→qualify→rank chain to one top-rankedREADY_TO_FIND_EMAILcandidate (freemium campaigns draw from the kit-ranked pool and mint the Deal on the fly), tries the free hub cache (contacts.resolve) → hit routes straight toREADY_TO_EMAILand queues the opener; hub miss →bettercontact.submitfires a job, parks the deal atFINDING_EMAIL, and schedules the firstcollect_emailpoll. No-op with no mailbox; couldn't-submit staysREADY_TO_FIND_EMAIL.handle_collect_email(tasks/collect_email.py) — the poll leg. Polls the payload'srequest_idonce (bettercontact.poll_once): hit →READY_TO_EMAIL+ hub give-back + queue the opener; miss →FAILED reason="no email"; still-running → chain the next poll with doubled backoff (COLLECT_BACKOFF_BASE_S·2^attempt, capped) or, pastCOLLECT_DEADLINE_S, revert toREADY_TO_FIND_EMAIL. A stale deal (no longerFINDING_EMAIL) drops the poll.handle_email(tasks/send.py) — picks the least-loaded under-capMailbox+ the oldestREADY_TO_EMAILdeal (core.db.deals.get_emailable_deals), materializes the profile summary, composes the opener (core/agents/email_opener.py), sends over SMTP (emails/sender.py, BCC = the operator's own address), then_record_sent_emailwrites the email fields, the outgoing openerChatMessage, andstate=EMAILED— send record + state on one row, so no double-send window.next_follow_up_atis seeded from the opener agent's ownfollow_up_hours.handle_follow_up(tasks/follow_up.py) — picks the oldest dueEMAILEDdeal whose bound box has headroom, runsrun_follow_up_agent(reads IMAP replies viaemails/inbox.py, decides), then executes:send_message→ threaded SMTP reply (In-Reply-To= latest message,References= thread root) + re-arm the clock;mark_completed→COMPLETEDwith the agent's outcome;wait→ pushnext_follow_up_atout.
Qualification ML Pipeline
GPR (sklearn, ConstantKernel * RBF inside Pipeline(StandardScaler, GPR)) with BALD active
learning, over 384-dim FastEmbed embeddings (BAAI/bge-small-en-v1.5) stored on Lead.embedding;
per-campaign models persisted in Campaign.model_blob (joblib, compress=3).
- Discovery feeds the pool:
core/pipeline/discover.py:discoverpages the campaign ICP (core/pipeline/icp.py, cached onCampaign.icp_filters) from Lead Finder into embeddedLeads; the qualify chain calls it both when its candidate pool goes dry and — in exploit mode — when the pool holds no candidate above the adaptivemin_positive_pool_probthreshold, so discovery stays interleaved with qualification instead of draining the whole page first. - Balance-driven selection —
n_negatives > n_positives→ exploit (highest P); else → explore (highest BALD). - LLM decision — every qualify decision is an LLM call (
qualify_lead.j2reading the lead's storedprofile_text); the GP is used only for candidate selection and the confidence gate. - Rank gate —
ready_pool.promote_to_readypromotesQUALIFIED → READY_TO_FIND_EMAILwhenP(f>0.5)exceedsmin_gp_confidence(0.9), so a paid credit is only ever spent on a ranked lead.
Cold start returns None until ≥2 labels of both classes; the daemon warm-starts each campaign's GP
from Lead.get_labeled_arrays at boot. Freemium campaigns use a pre-trained KitQualifier
(HuggingFace kit) instead of a warm-started GP.
Django Apps
core— Engine:SiteConfig,Campaign,Taskmodels; daemon, scheduler, LLM factory, onboarding, the ML/discovery/qualify pipeline, the two agents, session, geo, vendored mem0.emails— The email channel.bettercontact.py(paid finder: the two-legsubmit(query)→request_id+poll_once(request_id)→PollOutcome, the shared blockingsubmit_and_polltransport used by discovery,is_configured,BetterContactQuery/Result/PollOutcome/Unavailable);models.py(Mailbox+ the per-box daily-cap pacing manager +has_mailbox());icemail.py(parse_mailboxes— the App-Passwords sheet),smtp.py(verify_auth),mailbox_setup.py(import_mailboxes→ parse → auth-check → store);sender.py(send_emailover SMTP+STARTTLS, threading headers, BCC-to-operator);inbox.py(sync_inbox— IMAP reply-reader);newsletter.py(subscribe_to_newsletter, Brevo);tasks/(the four handlers: find_email, collect_email, send, follow_up).crm—Lead(identity + embedding + email) andDeal(crm/models/lead.py,crm/models/deal.py); also definesDealStateandOutcome.chat—ChatMessage, FK to the owningDeal(the per-(lead, campaign) conversation; the opener + every reply are rows here).legacy— model-less; migration-history anchor only (see Project Layout).contacts— the central contacts-store client (service.py, no models, not an installed app) — "the hub" (hub.openoutreach.app), logged under thehub:prefix.resolve(lead)(free read-back before the paid finder) andcontribute(session, lead, emails, origin)(give-back, non-EEA only, registers on first use). Both best-effort; an outage or missing token degrades to a no-op.
History note: the engine models (SiteConfig/Campaign/Task) lived in the LinkedIn app until
mid-2026 and were moved to core (state-only + table renames); the LinkedIn app was then emptied
to models and renamed legacy.
CRM Data Model
- SiteConfig (
core/models.py) — Singleton (pk=1).ai_model(pydantic-aiprovider:model; valid providers openai/anthropic/google/groq/mistral/cohere/openai_compatible),llm_api_key,llm_api_base(only foropenai_compatible:*),bettercontact_api_key(blank disables discovery + enrichment),contacts_api_token/contacts_api_url(token earned on first contribution; blank URL → default hub),country_code(ISO-3166 alpha-2 — the only persisted operator setting; drives the active-hours timezone viatz_countryand the email-jurisdiction rules viacore/geo).SiteConfig.load();core/llm.get_llm_model()turns it into apydantic_ai.models.Model. - Campaign (
core/models.py) —name(unique),users(M2M toUser),product_docs,campaign_target,booking_link,is_freemium,action_fraction,seed_public_ids,model_blob(per-campaign GP). Discovery:icp_filters(the cached Lead Finder spec{"filters": …, "country_code": …}, generated once by the LLM) +discovery_offset(the page cursor that lets discovery advance across cycles/restarts). - Lead (
crm/models/lead.py) — Keyed onprofile_url(unique — the discovery provider's per-person URL, the opaque identity/lookup key, stored, never fetched).country_code(stamped from the discovery ICP; drives the contacts-store geo-gate; blank → never contributed).embedding(384-dim float32 BinaryField, built at discovery).profile_text(the firmographic text — headline/location/industry/title/company/company-description, plus seniority, company-industry, location state+country, and company-keywords folded in when the row carries them — built from the Lead Finder row at discovery, the LLM qualifier's input; no re-scrape).email(the finder result; null = not found/unresolved — populated by the two-leg find_email→collect_email legs or a free hub-cache hit, never on the model itself).disqualified.to_profile_dict()→{lead_id, profile_url};embedding_arrayfor numpy;get_labeled_arrays(campaign)→ (X, y) for GP warm start (non-FAILED → 1, FAILED+wrong_fit → 0, other FAILED → skipped). Created browserless viacore/db/leads.create_lead(row, country_code)(or freemium seeds viacore/setup/freemium.py) — there are no scrape accessors. - Deal (
crm/models/deal.py) — campaign-scoped (unique(lead, campaign)).state(DealState),outcome(Outcome),reason(free text). Email fields:mailbox(FK to the sendingMailbox— the per-box-cap counting key, reply anchor, sticky thread box),email_subject(the opener's subject, reused as "Re: …"),email_sent_at(opener audit timestamp),email_message_id(the immutable thread root the IMAP reader matches replies on),next_follow_up_at(the agentic-loop cursor — seeded by the opener, re-armed each turn).profile_summary/chat_summary(lazy mem0-style JSON fact lists, campaign-scoped).creation_date,update_date. - Task (
core/models.py) —task_type(find_email/collect_email/follow_up/email),status(pending/running/completed/failed),scheduled_at,payload, timestamps.TaskQuerySet.pending()orders by opportunity-cost priority (follow_up > collect_email > email > find_email) then oldestscheduled_at;claim_next()takes the highest-priority due task, whileseconds_to_next()sleeps by earliestscheduled_atalone (never priority). Composite index on(status, scheduled_at). - ChatMessage (
chat/models.py) — FK to the owning Deal (related_name="messages").content,is_outgoing,owner,external_id(message identity for per-deal dedup — the email Message-ID; legacy LinkedIn rows hold a Voyager entityUrn),answer_to/topic(self FKs),creation_date. Dedup:unique(deal, external_id). The opener + every reply are rows here;Mailbox.sent_today()counts the outgoing ones for the per-box cap. - Mailbox (
emails/models.py) — one SMTP inbox:host/port(defaultsmtp.gmail.com:587),imap_host/imap_port(defaultimap.gmail.com:993— the read side for the reply loop, same app password),username,password,from_address,daily_limit(warm-safe sends/day, defaultDEFAULT_EMAIL_DAILY_LIMIT). A row exists only once its credentials pass the import auth-check (no health API). Manager:remaining_today()(Σ per-box headroom),least_loaded_under_cap(); instancesent_today()(outgoing ChatMessages on this box's deals since local midnight),headroom_today().has_mailbox()is the "email is a viable channel" gate.
Key Modules
Paths relative to openoutreach/.
core/daemon.py— worker loop with active-hours guard (seconds_until_active),Heartbeat+_HumanRhythmBreakpacing,_build_qualifiers(per-campaign GP warm-start / freemiumKitQualifier), freemium kit loading (fetch_kit→import_freemium_campaign→seed_profiles), startup + idlereconcile.core/scheduler.py— the only creator ofTaskrows:flush_find_email_queue(send-headroom-gated submit drain),flush_email_queue/flush_follow_up_queue(eager drains),schedule_collect_email(the bound, self-chaining poll), andreconcile. No Poisson pacing or spend cap.core/session.py—OperatorSession(browserless): holds the DjangoUser,campaigns(cached),self_profile(synthesized from the user +SiteConfigcountry — not scraped),active_timezone(ACTIVE_TIMEZONEoverride else the operator's country viatz_country, else None).get_active_user(),get_or_create_session().discovery.py— Lead Finder client:search(filters, limit, offset)(ICP search → lead rows, free),profile_text_for(row)+embed_row(row)(the qualifier's text/vector — the base fields keep the pre-pivot field order so old and new vectors stay comparable, withEXTRA_TEXT_FIELDS/EXTRA_TEXT_LIST_FIELDSappended only when present to enrich sparse rows). Sharessubmit_and_pollwithemails/bettercontact.py.core/pipeline/—icp.py(icp_for/generate_icp_spec: one LLM pass → Lead Finder filters, cached onCampaign.icp_filters),discover.py(discover: page the ICP into embedded Leads, advancediscovery_offset),qualify.py(run_qualification/fetch_qualification_candidates— readsLead.profile_text, no scrape),ready_pool.py(GP gate:promote_to_ready,find_ready_candidate),pools.py(composable generatorsqualify_source → ready_source → find_candidate;_needs_more_discoveryinterleaves discovery with qualification — pages in fresh leads when the pool is dry or, in exploit mode, holds nothing above the adaptive threshold),freemium_pool.py(find_freemium_candidate).core/ml/—qualifier.py(Qualifierprotocol,BayesianQualifier,KitQualifier,qualify_with_llm,format_prediction),embeddings.py(embed_text/embed_texts, cached FastEmbed model),hub.py(fetch_kit+ the download/load helpers — the HuggingFace campaign kit).core/setup/freemium.py—import_freemium_campaign(adds the DjangoUser),seed_profiles(seeds get a LinkedIn-shaped opaqueprofile_url, embeddings deferred to discovery),profile_url_from_slug.core/db/leads.py—create_lead(row, country_code)(persist one Lead Finder row as an embedded Lead, idempotent),promote_lead_to_deal,disqualify_lead.core/db/deals.py— Deal state ops:set_profile_state, the state-pool queries (get_qualified_profiles,get_ready_to_find_email_profiles,get_emailable_deals),create_disqualified_deal,create_freemium_deal._STATE_LOG_STYLEcolors the funnel transitions in the log.core/db/summaries.py— the single mem0-style LLM boundary.materialize_profile_summary_if_missing(deal, session)buildsprofile_summaryon first follow-up touch from the lead's storedprofile_text(no re-scrape);update_chat_summary(deal, new_messages, *, seller_name)folds newly-read replies intochat_summaryviareconcile_facts(mem0 ADD/UPDATE/DELETE/NONE); an identity binding (seller_name_from(session)) keeps the LLM from misattributing seller-name greetings in a lead reply. mem0's update prompt is vendored undercore/vendor/mem0/(nomem0airuntime dep).core/agents/—prompt.py(sharedrender/base_context/_format_facts; both agents extend_outreach_base.j2),email_opener.py(compose_opener_email→EmailDraft{subject, body, follow_up_hours}, the one-shot cold opener),follow_up.py(run_follow_up_agent→FollowUpDecision{action, message?, outcome?, follow_up_hours}; reads the IMAP-synced thread + a recency window of verbatim messages; single structured LLM call, no tool loop).core/llm.py—get_llm_model()factory (readsSiteConfig,split_model_idparses the provider out ofai_model, dispatches to the per-provider builder),build_llm_model(from explicit creds),verify_llm_credentials(one live ping, tenacity-retried, used by onboarding), andrun_agent_sync(coro)— the sync boundary that drives async pydantic-ai on a dedicated long-lived worker-thread loop (neverAgent.run_sync, whose anyio portal poisons the caller thread's loop slot; never per-callasyncio.run, which closes loops the SDK HTTP clients still reference).core/geo.py— jurisdiction sets + predicates:is_gdpr_protected(broad opt-in set, drives the newsletter default) andis_eea_located/EEA_UK_CH(narrow EEA/UK/CH collection-regime set — the client-side pre-gate for contacts-store contribution; the server re-gates authoritatively). Country codes come from onboarding / the discovery row, never from a scrape.core/tz_country.py—timezone_for_country(code)(pytzcountry_timezones) for the active-hours window.core/logging.py—configure_logging+print_banner;SILENCED_LOGGERSquiets urllib3/httpx/pydantic_ai/openai/fastembed/etc.core/migration_compat.py+management/commands/migrate.py— relabellinkedin → legacyindjango_migrationsbefore Django's consistency check, so pre-pivot installs upgrade with a plainmigrate.contacts/service.py— the hub client:resolve(lead)(free read before the paid finder;/resolvereturns anemails[]list, first taken),contribute(session, lead, emails, origin)(give-back at a fresh paid hit, non-EEA only, registers + mints the token on first use; optionally attaches the cached embedding). ReadsSiteConfig.contacts_api_token/contacts_api_url.
Configuration
SiteConfig(DB singleton) — see CRM Data Model. Editable via Django Admin.conf.pyschedule —ENABLE_ACTIVE_HOURS(False→ 24/7),ACTIVE_START_HOUR(9),ACTIVE_END_HOUR(19),ACTIVE_TIMEZONE(None→ resolved at runtime from the operator's onboarding country viatz_country; set an IANA name to pin it). The daemon and scheduler primitives take the resolved zone as an argument and log its provenance. Single contiguous window, no weekend handling.conf.pycollect backoff —COLLECT_BACKOFF_BASE_S(5),COLLECT_BACKOFF_MAX_S(60),COLLECT_DEADLINE_S(600): thecollect_emailpoll doubles its delay each still-running attempt (capped at MAX), giving up past DEADLINE. There is no spend cap — paidfind_emailspend is gated by mailbox send-headroom (flush_find_email_queue), so a lookup only fires when its result could be sent today.conf.py:DEFAULT_EMAIL_DAILY_LIMIT(30) — the per-mailbox warm-safe send ceiling set at onboarding and stored on eachMailbox(enforced at send time, per box).conf.py:CAMPAIGN_CONFIG—min_gp_confidence(0.9, the GP rank gate),min_positive_pool_prob(0.20, base of the adaptive discovery-interleave thresholdmax(0, base - 1/sqrt(n_obs))),qualification_n_mc_samples(100),embedding_model(BAAI/bge-small-en-v1.5), and the human-rhythm knobsburst_min/max_seconds(45–65 min),break_min/max_seconds(10–20 min).- Prompt templates (
core/templates/prompts/) —icp_filters.j2,qualify_lead.j2,_outreach_base.j2(shared base),email_opener.j2,follow_up_agent.j2. requirements/—base.txt,local.txt,production.txt,crm.txt(empty).
Docker
Multi-stage build from python:3.12-slim-bookworm using uv (no browser, no VNC).
compose/openoutreach/Dockerfile. BUILD_ENV arg selects
requirements; data persists in a volume at /app/data.
CI/CD
tests.yml— pytest on push / PRs.deploy.yml— onv*tags: build + push toghcr.io/eracle/openoutreach(tagslatest,sha-<commit>, semver).
Dependencies
requirements/ files; uv pip install for fast installs. No browser/Playwright, no DjangoCRM.
Core: Django, pydantic, pydantic-ai-slim (with openai/anthropic/google/groq/mistral/cohere/bedrock extras; griffe pinned <2), jinja2, pandas, termcolor, tenacity, questionary, tendo, pyyaml, jsonpath-ng
ML: scikit-learn, fastembed, huggingface_hub, numpy/joblib (transitive)