π‘ Partially live. The spine is real and shipping β Segments, the visual
Workflow engine, tracked/compliant sends, attribution, and call tracking all
run in production. But several marquee pieces are dormant or inert-by-default:
linear Drip sequences have been superseded by Workflows (drip engine still
ticks but nothing new should enroll into it), inbound email (and therefore
stop-on-reply) is off until MX is wired, and GoBuild's own demo funnel
runs on a separate Mautic droplet β not the tenant CRM described here.
Audience: π οΈ Builder / operator Β· π§βπ» Engineer Β· π£ Marketer
See also: Marketing CC Β· the CRM/leads reference Β· the glossary
(house terms: a unit of work is a Job; the person you build for is a Client).
| Badge | Meaning |
|---|---|
| π’ | Live and wired end-to-end |
| π‘ | Live but partial / caveated |
| βͺ | Built but dormant (code exists, nothing drives it by default) |
| β | Not built |
GoBuild mirrors HubSpot's automation mental model
(docs/nurture-marketing-buildout.md):
Form / Trigger β Segment β Workflow β Email/SMS β Score β (sales handoff), grouped by Campaign.
Everything hangs off a shared audience layer (Segments) and a shared tracked
send spine (EmailSend). The one control-plane router serves two front ends:
app/routers/marketing_cc.pymarketing_command_center.html and fans out JSON panes (calls, numbers,app/routers/marketing.py:53 serves the CC shell unless ?classic=1.?classic=1 β Dashboard Β· Segments Β· Workflows Β· Emails Β·app/routers/marketing.py). Same models underneath; no parallel features.Both are cross-linked from Marketing CC.
The backbone. A Segment (app/models/marketing.py:26) has a kind:
dynamic β a live JSON criteria filter tree evaluated on readapp/services/segments.py, evaluate / member_count). Auto-updates as People change.static β a frozen snapshot; seg_svc.freeze() writes member_ids at saveapp/routers/marketing.py:139, :252).manual β hand-picked members (checkbox picker atapp/routers/marketing.py:200, plus a "seed N random contacts" helper at :218).Segments target People (the unified contact identity), and feed campaigns,
workflows, and the dashboard. A live audience preview (count + 10-row sample)
backs the visual builder: POST /portal/marketing/segments/preview
(app/routers/marketing.py:108).
AI segment drafting π’ β "describe an audience in plain English β segment rules":
POST /portal/marketing/segments/ai (app/routers/marketing.py:149) calls
ai_assistant.generate_segment (app/services/ai_assistant.py:1268), which runs on
DeepSeek (see Β§8). The CC has a mirror at /portal/marketing/audiences/ai.
The marquee build. A Workflow (app/models/marketing.py:41) stores a Drawflow
node graph as JSON in graph, plus status (draft|on|off), a trigger JSON,
reenroll, and stop_on_reply. Enrollments live in WorkflowEnrollment
(app/models/marketing.py:65) carrying current_node, next_run_at, and status
(active|done|stopped|replied|goal).
app/services/workflows.pyThe engine walks the saved graph node-by-node. Node types (_run_action,
app/services/workflows.py:159):
| Node | Behavior | Code |
|---|---|---|
trigger |
Entry marker (skipped in execution) | _start_node :71 |
delay |
Reschedule next_run_at by N days, then return |
:272 |
branch |
Evaluate a segment-style condition β output_1 (yes) / output_2 (no) |
_eval_branch :153 |
email |
Tracked, compliant send via marketing_send (template slug or inline HTML) |
:164 |
sms |
Best-effort text via Chatwoot's Twilio inbox β org's own Twilio fallback, with a suppression circuit-breaker | :178 |
tag |
Append a tag to the Person/Lead | :209 |
field |
Set lead.confidence or lead.status |
:219 |
task |
Create a lead follow-up task | :226 |
goal / stop |
Terminate the enrollment (goal vs done) |
:281 |
advance() (:236) runs each due enrollment with a 50-node loop backstop
(_GUARD); run_due() (:302) advances every enrollment whose next_run_at has
passed. Enroll-once by default β a duplicate active enrollment is skipped and, if
reenroll is off, a contact who ever enrolled won't re-enter (enroll, :89).
POST /workflows/{wid}/enroll, :450).on enrolls current memberssave_workflow, app/routers/marketing.py:438).trigger_eventapp/services/workflows.py:319), fired from the sales pipeline.The "quick-start email sequence" (POST /portal/marketing/sequences,
app/routers/marketing.py:290) builds a linear trigger β email β delay β β¦ β goal
graph via wf_svc.linear_graph() (app/services/workflows.py:25) and opens it in the
visual builder. There is one engine, not two.
AI workflow drafting π’ β ai_workflow (app/routers/marketing.py:338) β
generate_workflow (app/services/ai_assistant.py:1289) drafts 3β5 steps on DeepSeek.
House rule / drips. The old
DripSequence/DripEnrollment/DripSend
engine (app/models/lead_followup.py,app/services/lead_followup.py:163
send_due_drips) was superseded by Workflows. See Β§5.
A Campaign is a one-to-many blast to a Segment or an imported list, tracked the
same way as workflow email. Front ends:
app/routers/email_studio.py (/portal/email-templates + campaigns)marketing_send.send_marketing_email (email_studio.py:191), one EmailSend persource="campaign", source_id=campaign.id).marketing_cc.py:988 launch β same marketing_send path:992), recipients set from a segment:915) or imported (:946).AI campaign drafting π’ β ai_campaign (email_studio.py:237) β
generate_campaign (app/services/ai_assistant.py:1311), DeepSeek.
app/services/marketing_send.py π’Every marketing email β workflow and campaign β funnels through
send_marketing_email (marketing_send.py:82):
is_suppressed, :49) β never send to anSuppression, app/models/marketing.py:103).EmailSend row with an open-pixel + click-redirect token.unsub_token, :31) appended tolead_nurture email type β kill-switch, branded shell, andreply_to set to marketing_reply_to when configured β the hook forOpens/clicks are recorded on EmailSend; the workflow builder's Recipients pane rolls
them up per person (app/routers/marketing.py:382).
The nurture page (app/routers/nurture.py) still lists and creates DripSequence
rows, and the scheduler still calls send_due_drips each tick β but its own comment
flags it: lead_followup.send_due_drips(db) # legacy drips (dormant)
(app/services/scheduler.py:313). The engine is not a no-op β if an enrollment
exists it will send β but the marketing hub no longer enrolls into drips.
β οΈ Nuance / not fully dead. Drips can still be enrolled from the sales
surface βenroll_lead_drip(app/routers/sales.py:802) and
sales_cc.py:459. So a lead detail page can still put someone on a drip alongside
Workflows (wf_svc.enroll,sales.py:794). TheDripSequence β Workflow/
DripEnrollment β WorkflowEnrollmentmigration the buildout doc calls for
(docs/nurture-marketing-buildout.mdΒ§5 Phase C) is not finished.
Workflows support a stop-on-reply exit (Workflow.stop_on_reply defaults True,
app/models/marketing.py:58; enforced in advance() at workflows.py:256 via
_has_replied, which checks inbound Communications + booked Appointments).
But the inbound signal is off by default. The inbound email receiver
(app/routers/webhooks.py:169 /api/webhooks/email/inbound) is gated on
RESEND_INBOUND_SECRET β if unset it returns 404 (feature off)
(webhooks.py:187). And marketing_send only sets reply_to when
marketing_reply_to is configured. So until MX/inbound routing is wired for a tenant,
stop_on_reply has nothing to fire on for email replies. (Inbound SMS and
Chatwoot webhooks do land, so a text reply / booked meeting can still trip it.)
Distinct from marketing sends: System Emails is the catalog of every
transactional/automatic email GoBuild sends (app/routers/email_dashboard.py,
/portal/email). Backed by email_registry (app/services/email_registry.py) β
categories: Automatic (system-triggered, no one clicks send), etc. Each type has:
EmailSetting, email_dashboard.py:104) β aEmailTemplate for store="template"EmailSetting for store="setting" slugs);SentEmail, email_dashboard.py:82).The marketing spine reuses this: marketing_send routes through the lead_nurture
registry type (email_registry.py:59, "Lead nurture (drip)"), so a tenant can kill all
nurture mail with one toggle and every send lands in the same log. The dashboard tab is
grouped under active_nav="marketing".
The capture layer β how a click becomes an attributed Lead, and a phone call
becomes a touchpoint.
app/services/attribution.pyclassify (:76) β raw utm params + click ids (gclid/msclkid) + referrertouch (:140) β upsert an anonymous VisitorSession keyed by agb_vid), first-touch source written once and neverTouchpoint. A coarse device_key = sha256(ip|ua)device_fingerprint, :52).stitch_to_lead (:254) β on form submit / booking / login, attach thesource_category bucket, and drop a form_submit touchpoint.Runs with no external service on every existing lead path. ping (:203) is a
presence heartbeat that deliberately does not create sessions/touchpoints (so
heartbeats don't pollute attribution).
app/services/call_tracking.pyPer-channel Twilio tracking numbers (provision_tracking_number, :53). Inbound
call β build_incoming_twiml (:103) forwards to the contractor's real line and
records in the middle (callerId preserved). match_identity (:148) ties the caller
to a Person/Lead by phone; every call drops a call touchpoint
(_record_call_touchpoint, :186). When a recording completes, attach_recording
(:214) transcribes + summarizes it and writes transcript / ai_summary /
sentiment / outcome.
AI-split rationale (call audio): transcription is deliberately US-hosted
(Gemini), never DeepSeek-direct, because "call audio is sensitive PII"
(call_tracking.py:10,:245).
Two providers, split by data-sensitivity and task:
| Provider | Module | Used for |
|---|---|---|
DeepSeek (chat) |
app/integrations/ai/deepseek.py |
Text generation from tenant marketing text β generate_segment (ai_assistant.py:1268), generate_workflow (:1289), generate_campaign (:1311), blog content_planner (app/services/content_planner.py:19). |
Gemini (gemini.*) |
app/integrations/ai/gemini.py |
Grounded web search (trend research, ai_assistant.py:992+), vision/extract, image generation, and audio transcription of call recordings (call_tracking.py:245) β the US-hosted, PII-safe path. |
Rule of thumb: DeepSeek drafts marketing copy/rules from text; Gemini handles
anything grounded-in-web, visual, or PII-sensitive (call audio). All DeepSeek entry
points raise AIError and degrade gracefully (the caller keeps an empty/default draft).
Listed as sources but they sit at the pipeline edges rather than in the automation
engine:
app/services/conversion.py π’ β the "land" loop: proposal accepted βwon outcome), not a marketing send.app/services/conversations.py π’ β the Messages command center: mergesCommunication (email/SMS, incl. inbound) into onestop_on_reply reads from.app/services/co_schedule.py π’ β change-order β schedule sync. Not marketingDo not confuse the two. Everything above is the tenant-facing engine a builder
runs on their own leads. GoBuild's own prospect nurture runs on a separate
Mautic instance (mktg.gobuild.ca), on its own CRM/marketing droplet.
/api/demo-request endpointapp/routers/marketing_forms.py:128) email the sales inbox + auto-reply thepush_to_mautic,marketing_forms.py:31, MAUTIC_FORM_URL β mktg.gobuild.ca/form/submit?formId=1)demo-usa-cold, demo-canada-cold, the 3-email Book-a-Demodocs/precall_email_campaigns.md, docs/mautic_demo_email_sequence.md. They use{contactfield=firstname|there}), Mautic segments, and Mautic'sπ‘ Status caveat: these docs are build specs for Mautic. Whether the Mautic
droplet is currently live and the campaigns are loaded/running is an operational
question (see open questions).
nurture-marketing-buildout.md Β§5) isDripSequenceβWorkflow / DripEnrollmentβWorkflowEnrollmentenroll_lead_drip is live on the lead detailsales.py:802, sales_cc.py:459) even though the marketing hub only buildsRESEND_INBOUND_SECRET gates the receiverwebhooks.py:187) and marketing_reply_to gates the reply-to. Are either set formarketing_cc.py) or themarketing.py)? Both create Workflows/Segments/Campaigns via?classic=1 a deprecation path or a permanent power-user view?mktg.gobuild.ca β are demo-usa-cold /demo-canada-cold / the Book-a-Demo sequence actually loaded and sending, withPerson.score >= 80 for "hot leads"marketing.py:79) and scoring.recompute_all runs each tickscheduler.py:318). Is the fit + engagement scoring (Phase D) fully implemented, orscore still close to the old confidence hot/warm/cold?marketing_send checks Suppression on send, but is/u/{token}?enroll_workflow_id, marketing.py:539). Is groupedβ?