Status: 🟢 Built & fully wired end-to-end · Audience: GoBuild staff (eng, sales, ops) — internal.
Every piece below exists in code and is plumbed together: the tracker POSTs, presence computes, the widget mounts, the concierge replies and captures leads. Two things are not green: (1) it is unconfirmed whether the widget is actually deployed/embedded on any live site today (see Open questions); (2) the concierge impersonates a human and several lead-routing values are hardcoded to GoBuild's own inbox regardless of tenant — a posture decision, not a bug per se, but load-bearing. See Abuse & risk surface and known-risks.
Status badges: 🟢 Live · 🟡 WIP / config-gated · ⚪ Dormant · ❌ Dead-end / vaporware
Cross-reference: Marketing Command Center (this is the same VisitorSession / attribution layer the Marketing CC reads).
House terms: a Client is a contractor org on GoBuild; a Job is a project. On this page the parties are the owner (the contractor / GoBuild staff operating the Live app) and the visitor (an anonymous website browser).
Three cooperating surfaces, all first-party:
app/static/site/track.js) — a tiny snippet on the contractor's public site that reports page views + a presence heartbeat.app/routers/live.py, an installable PWA at /live) — a staff-only board showing who's on the site right now, where from, on which page, for how long — plus push alerts and a chat console.app/static/site/live-widget.js + app/routers/chat_public.py) — an on-page chat bubble the visitor talks into. Behind it, an optional Gemini AI concierge (app/services/live_ai.py) auto-replies as the owner and captures the visitor as a lead.There is no separate chat store: every message is a Communication row on a Lead, so the whole thread also lands in the hub Messages inbox (app/services/live_chat.py:1-9, 47-55). The visitor↔lead link is VisitorSession.lead_id.
Confirmed: there are no websockets anywhere in Live. Presence and chat are pure HTTP polling. (The only WS in the Caddyfile are for unrelated services — Chatwoot, Wiki.js — Caddyfile:41-45,64-67.)
track.js does two things (app/static/site/track.js):
{url, referrer, title, slug, utm_*} to /api/track via navigator.sendBeacon (falling back to fetch with keepalive) (track.js:25-38).setInterval every HEARTBEAT_MS = 15000 (15s) POSTs to /api/track/ping, keeping last_seen fresh. It self-stops after MAX_MIN = 30 minutes idle (track.js:51-67). It keeps beating even when the tab is backgrounded (the browser throttles to ~1/min, still inside the live window) so a mid-chat visitor doesn't drop off the board (track.js:46-50), and fires an instant ping on refocus (track.js:68-70).Privacy stance baked into the file: first-party only, no third-party requests, no fingerprinting/cross-site ids, so it is deliberately not gated on DNT/GPC — those signals target cross-site tracking and data sale, neither of which happens here (track.js:1-11).
app/routers/track.py (prefix="/api/track"):
POST /api/track → collect() (track.py:43-127). Resolves the org from the Host header via resolve_tracking_org (track.py:69-70), manages the HttpOnly, server-owned gb_vid cookie (track.py:75, 122-126; cookie defined attribution.py:VISITOR_COOKIE), and records a VisitorSession + page_view touchpoint through attribution.touch. Returns 204 always — a tracking error must never surface to a visitor (track.py:118-119), and an unresolved host silently 204s (track.py:71-72).POST /api/track/ping → ping() (track.py:130-172). Refreshes last_seen + current page on the existing session only (no new touchpoint). Crucially, the heartbeat is the trigger point for the proactive AI greeting — it fires live_ai.proactive_engage in the background once the concierge toggle is on for this host (track.py:160-171).Enrichment: on collect, a background task resolves the visitor's physical location from IP (geoip.enrich_session) when we don't already have a city (track.py:99-101). New-visitor / returning-lead push alerts are fired here too (track.py:103-117).
Not to be confused with
app/routers/tracking.py— that's the unrelated email open/click pixel + unsubscribe layer for drip nurture (tracking.py:1-6), not visitor presence.
The Live board reads presence from VisitorSession, staff-authed.
"Live now" window: a visitor counts as live if last_seen_at is within LIVE_WINDOW_SEC = 70 seconds (live.py:29) — roughly 4½ heartbeats of slack over the 15s beat. Auth is signed-in staff only (owner/pm/office, not field crew — live.py:30,41-48).
GET /live/visitors (live.py:224-271) is the board's data source:
last_seen_at >= now-70s (live.py:242-247).live.py:233-244).lead_id, person_id, or device_key (a coarse sha256(ip|user_agent) fingerprint — a grouping hint, never a hard identity; attribution.device_fingerprint). See _group_identities (live.py:163-191) and _merge_card (live.py:194-221). Result: "one visitor · 3 visits", not three rows._source_label (where from), _device (coarse UA, no fingerprinting — live.py:56-71), _location_label (🇨🇦 Toronto, ON from IP — live.py:82-92), net_org (ISP owner).Sibling endpoints: /live/log (paginated activity, one row per folded visitor — live.py:287-324), /live/visitor/{id} (full context + page trail sessionized on a 30-min gap — live.py:336-393, SESSION_GAP at live.py:137), /live/alerts, /live/test-alert (fires a test web-push).
The PWA shell itself is GET /live (live.py:642-652), with its own scoped service worker (/live/sw.js, live.py:686-723) and manifest (live.py:671-683). Icons come from /site-assets/live-192.png / live-512.png.
Also pure polling — no websockets.
app/static/site/live-widget.jsSelf-contained, first-party, injected via one <script> tag. It:
GET /api/chat/config → agent name + avatar; live-widget.js:92-101).GET /api/chat/history; live-widget.js:191-205).GET /api/chat/poll every 4000ms (setInterval(poll, 4000), live-widget.js:262), plus an instant poll on tab refocus (live-widget.js:263).POST /api/chat/send (live-widget.js:181-188).splitChunks + nextChunk, live-widget.js:206-261) — part of the seamless-human illusion.read_through (live-widget.js:165-169).BRAND defaults to "GoBuild" (live-widget.js:71-76).live-widget.js:12-14).app/routers/chat_public.py (prefix="/api/chat")All resolve org by Host header (+ optional slug fallback), all cookie-scoped to gb_vid:
| Endpoint | Purpose | Ref |
|---|---|---|
GET /api/chat/poll |
owner→visitor messages newer than cursor (out-only) | chat_public.py:39-55 |
POST /api/chat/send |
visitor→owner; mints a session/lead if none, threads into inbox, kicks the AI | chat_public.py:195-257 |
GET /api/chat/history |
full thread (both directions) to restore after refresh | chat_public.py:179-192 |
POST /api/chat/demo |
inline demo-form submission → Book-a-demo workflow | chat_public.py:58-127 |
GET /api/chat/config |
concierge display name + avatar for this host | chat_public.py:130-153 |
GET /api/chat/avatar/{org_id} |
concierge profile picture (public) | chat_public.py:156-176 |
The Live app operator polls GET /live/thread/{visitor_id} (live.py:593-616) and sends with POST /live/say-hi → live_chat.operator_say (live.py:396-414). Messages both ways are the same Communication "out"/"in" rows (live_chat.py:47-55), so the whole conversation also appears in the hub Messages inbox with no parallel system.
app/services/live_ai.py. When the toggle is on (default) and no human is actively replying, Gemini answers website visitors, and the design goal is stated plainly in the module docstring: "Seamless by design: the visitor can't tell it's AI." (live_ai.py:13-16).
The system prompt instructs the model to pose as a named human on the GoBuild team and to never reveal it's AI:
live_ai.py:72-74)live_ai.py:100-102)live_ai.py:129, _concierge default live_ai.py:277).live_ai.py:88-90; live-widget.js:206-261).Messages post as ordinary Communication "out" rows via live_chat.ai_say, tagged with author AI_AUTHOR = "GoBuild Assistant" only so the owner-side UI can badge them — the visitor never sees that marker (live_chat.py:58,69-75). The [[demo_form]] trigger token is stripped before the operator sees it (live.py:613-615).
live_ai.py:10-11) — a deliberate data-residency choice (contrast the DeepSeek H1 item on known-risks).DEFAULT_KB, live_ai.py:38-60) + the page the visitor is on + the chat so far. No CRM/customer data is ever handed to it, and it's told never to invent pricing/names/promises (live_ai.py:8-11,103-104).live_ai.py:110-126, 332-334).PROACTIVE_MIN_DWELL = 12s on site (live_ai.py:33), proactive_engage says hi once per visitor, enforced identity-wide via greeted_at across every session sharing a lead/person/device key (_identity_greeted, live_ai.py:394-411; proactive_engage, live_ai.py:414-472). Triggered off the heartbeat (track.py:160-171).engage (live_ai.py:295-391) checks the toggle, bails if a human replied in the last _OWNER_QUIET_MIN = 5 min (_owner_handling, so AI never talks over a person — live_ai.py:238-245), marks inbound read, generates a reply, then captures.These are constants, not per-org config:
SALES_INBOX = "gautam@gobuild.ca" — where captured leads are emailed (live_ai.py:30).DEMO_URL = "https://gobuild.ca/demo" (live_ai.py:31).send_demo_request(..., notify_to=["gautam@gobuild.ca", "sales@gobuild.ca"]) — hardcoded in both the AI path (live_ai.py:373) and the inline-form path (chat_public.py:114).DEFAULT_KB describes GoBuild the SaaS (scheduling, CRM, QuickBooks sync…), not the contractor's own business.Implication: on a Client's own site, unless the owner overrides live_ai_kb, the concierge pitches GoBuild the platform and routes the captured lead to GoBuild's inbox — not the contractor's. This is fine for GoBuild's own gobuild.ca marketing site but is a real concern for any tenant deployment. See Open questions.
Stored in org preferences and read by _concierge (live_ai.py:272-292):
live_ai_on (default True), live_ai_name (default "Jordan"), live_ai_kb (override brief, ≤8000 chars), plus a Gemini-generated brand-aware avatar. Managed via /live/ai-settings (live.py:452-590) and /live/ai-avatar (live.py:468-532).live_ai_on / live_ai_name / live_ai_kb via resolve_folder_host, so gobuild.ca's official chat stays separate from demo sites in the same org (live_ai.py:279-292, chat_public.py:144-152).Two capture paths, both ending in the shared Book-a-demo workflow (marketing_forms.send_demo_request, team notification + prospect confirmation + Mautic push):
extract_contact (live_ai.py:186-227) pulls email/phone by regex (reliable) plus a single Gemini JSON call for name/company/team_size/timeline/etc. It sets ready_to_book = true only when name + email + (a time or phone) are present and the visitor isn't mid-question (live_ai.py:208-213). engage submits exactly once, gated on ready_to_book + a real name, stamping [demo-submitted] on the lead notes to prevent doubles (live_ai.py:357-387).[[demo_form]] token, the widget renders a mini form (live-widget.js:118-164) that POSTs to /api/chat/demo → same workflow + notify_lead_captured alert (chat_public.py:58-127).If the AI toggle is off, the owner is handling chats manually, so each visitor message instead fires notify_chat_message to ping the owner (chat_public.py:230-243).
Both assets live in the hub at app/static/site/ and are mounted at /site-assets/* (app/main.py:307). Current cache-busting versions: live-widget.js?v=14, track.js?v=4.
templates/landing_public.html:159-167): the widget loads unconditionally on any non-preview render; the tracker loads with window.__gb_slug set.app/routers/landing_portal.py:396-399): widget injected only when folder.live_ai_on is true (config-gated).app/services/blog.py:237-242): injects __gb_brand / __gb_agent / __gb_slug, the widget, and the tracker.gobuild.ca, served by Caddy from /srv/marketing, outside this repo): resolves to the configured GoBuild org via settings.gobuild_marketing_org_id + gobuild_marketing_hosts (landing_portal.py:50-61, config.py:262-263). Whether the marketing HTML actually includes the <script> tags is not verifiable from this repo (that tree isn't here) — see Open questions.The tracker/chat endpoints and the shared assets must be same-origin with the site so the HttpOnly gb_vid cookie is sent (cross-origin would drop it). Caddy proxies them into the hub on the marketing host (Caddyfile:71-93):
handle /api/track* { reverse_proxy hub:8000 }
handle /api/chat* { reverse_proxy hub:8000 }
handle /site-assets/*{ reverse_proxy hub:8000 }
handle { root * /srv/marketing; file_server } # static pages
Builder subdomains and partner custom domains already hit the hub for everything (Caddyfile:16-28,98-110), so the same paths resolve there natively. Org resolution is entirely Host-header driven (resolve_tracking_org, landing_portal.py:39-61), which is what makes one shared widget work across gobuild.ca, *.gobuild.ca builder sites, and custom domains.
/api/track* and /api/chat* are unauthenticated by design — they must accept hits from any anonymous browser. That's unavoidable, but it's an exposed surface. Cross-link: known-risks.
/api/track has a rate limit — a per-IP cap of 240 hits / 300s (track.py:54-56), over-limit silently 204s. Reasonable flood protection for analytics./api/chat/send has NO rate limit. An unauthenticated caller can POST arbitrary messages, which mints a gb_vid, creates a Lead + Communication rows, and triggers a background Gemini call (and potentially outbound email on capture) per request (chat_public.py:195-257, live_ai.py:295-391). This is a spam / cost-amplification / inbox-flood vector: each message can burn Gemini tokens and, once "booked", send email to gautam@gobuild.ca / sales@gobuild.ca. No CAPTCHA, no rate limit, no per-session cap is present in code./api/chat/demo likewise fires the full Book-a-demo workflow (team email + prospect confirmation) on any well-formed anonymous POST, unthrottled (chat_public.py:58-127).live_ai.py:100-102). That's a disclosure/consumer-protection posture question, not a code bug — flagged for an owner decision (see below).Concrete items needing an owner/eng decision — not answerable from code alone:
live_ai.py:100-102, docstring live_ai.py:13-16). Some jurisdictions (e.g. proposed/existing bot-disclosure laws) require disclosure. Is this a deliberate, signed-off product decision or an unreviewed default?gobuild.ca's static marketing HTML (in /srv/marketing, outside this repo) include the /site-assets/live-widget.js + track.js tags? Is any real Client site running it in production?SALES_INBOX, notify_to, and DEMO_URL are hardcoded to GoBuild's own inbox/domain (live_ai.py:30-31,373, chat_public.py:114). On a Client's own site the captured lead would email GoBuild, not the contractor. Intended (GoBuild-marketing-only feature) or a gap that blocks tenant use?DEFAULT_KB (live_ai.py:38-60) pitches the platform. On a contractor's site without a live_ai_kb override, the concierge talks about the wrong company. Should the KB default be tenant-derived (brand/services) instead?/api/chat/send has no rate limit / CAPTCHA. Given it drives Gemini spend + outbound email unauthenticated, what throttle (per-IP, per-session message cap, cooldown) do we want before any public exposure?live_ai_on defaults to True. Any org (and every unconditionally-widget-injected builder site, landing_public.html:161) gets an AI that speaks as GoBuild by default. Is opt-out-by-default acceptable, or should this be opt-in per Client?Communication rows, live_chat.py:58,73)?/live/ai-avatar calls Gemini image generation (live.py:468-532); it's staff-authed, but is there any budget cap on regeneration?