Status: 🟡 Built & wired, but config-gated and single-account. The hub↔Chatwoot plumbing is real end-to-end — contacts sync, stage labels push, crew threads mirror, website chats capture into leads. But (1) it only runs when
chatwoot_api_token+chatwoot_account_idare set, and (2) it drives ONE shared managed Chatwoot account for every tenant, with no per-org inbox isolation enforced in code. See The one-account isolation flag and Open questions.Audience: GoBuild staff (eng, ops) — internal.
Status badges: 🟢 Live / built & wired · 🟡 Built but config-gated / partial · ⚪ Dormant · ❌ Dead-end / not built
House terms: a Client is a contractor org on GoBuild; a Job is a project. On this page the third parties are a contact/lead (a human in Chatwoot) and an agent (office staff working the Chatwoot inbox).
Cross-references: Integrations catalog · GoBuild Live (Live's native messenger is a separate chat store — see How this differs from GoBuild Live).
Chatwoot is a self-hosted, open-source omnichannel support/messaging platform — GoBuild's answer to Intercom/Zendesk. It gives office staff one unified agent inbox that merges web-widget chat, SMS, email, WhatsApp, Telegram, etc. into per-contact conversations.
GoBuild runs it as a set of containers in the hub stack (docker-compose.prod.yml:127-198):
chatwoot-rails — the Rails web app, image chatwoot/chatwoot:v4.14.1-ce, listening on :3000 (docker-compose.prod.yml:152-184). Boot command clears a stale Puma PID so a snapshot/OOM can't crash-loop it (:157).chatwoot-sidekiq — background job worker, same image (:186-198).chatwoot-db — its own Postgres 16 + pgvector (pgvector/pgvector:0.8.2-pg16, :128-142).chatwoot-redis — its own Redis (:144-150).It is served publicly at chat.gobuild.ca, reverse-proxied by Caddy straight to the container (Caddyfile:42-45). FRONTEND_URL defaults to https://chat.gobuild.ca (docker-compose.prod.yml:164), and the hub surfaces that URL to agents as chatwoot_url (app/config.py:249).
How the hub talks to it: internally, not over the public domain. The API client hardcodes http://chatwoot-rails:3000 over the docker network (app/integrations/chatwoot/client.py:13) and authenticates with the account admin's api_access_token header (client.py:29-33). Everything is Chatwoot's v1 account-scoped REST API — base path /api/v1/accounts/{account_id} (client.py:25-26).
Config knobs (app/config.py:248-257):
| Setting | Purpose |
|---|---|
chatwoot_url |
Public/agent URL (chat.gobuild.ca) |
chatwoot_api_token |
Account admin access token |
chatwoot_account_id |
The single account the hub drives |
chatwoot_website_token |
Web-widget inbox token |
chatwoot_sms_inbox_id |
Twilio SMS inbox for outbound routing |
chatwoot_webhook_secret |
Shared secret gating the inbound webhook |
is_configured() returns true only when both chatwoot_api_token and chatwoot_account_id are set (client.py:20-22). Everything downstream short-circuits when it's false — so on a hub without those env vars, all of this is dormant.
The hub's Person model is the spine; Chatwoot holds a mirror contact per person, keyed by Person.chatwoot_contact_id (app/services/chatwoot_sync.py:1-6).
sync_person() (chatwoot_sync.py:33-65) is the workhorse:
:35)._e164, :19-30) — an unrecognized number is dropped rather than sent as garbage.client.search_contact, :42-43); reuses the match if found.identifier=person-<id> and custom attributes hub_person_id + roles (:47-54). On a create failure (usually a duplicate email/phone) it re-searches and adopts the existing contact (:55-58).Person, commits, and pushes tags (:61-64).Chatwoot's term for tags is labels, stored as a flat set. sync_tags() / _desired_labels() push the union of (a) the person's hub tags and (b) one stage_<status> label per lead that person owns (chatwoot_sync.py:68-123). Because labels are a flat set, the hub always sends the full desired set to avoid clobbering stage labels. Labels are normalized lower().replace(" ", "_") on the way out (client.py:96-99).
A repeat customer with two open leads carries both stage labels — a deliberate "tag per-lead, accept multiples" decision (:101-110).
sync_lead() (chatwoot_sync.py:158-183) flattens a lead's CRM fields into the contact's custom_attributes so they show in the agent's contact panel — lead_stage, deal_value, lead_source, projected_close_date, lead_owner, and site/mailing/billing addresses (_lead_attributes, :126-155). It's called from the sales router after nearly every lead mutation: create, edit, stage change, convert (app/routers/sales.py:176, 318, 503, 544, 662, 1153).
pull_from_chatwoot() (chatwoot_sync.py:276-343) imports contacts that originated in Chatwoot (e.g. a widget visitor who started chatting) into the hub People directory, matching by Chatwoot id / email / phone to avoid duplicates, and merges Chatwoot labels back into hub tags (_merge_labels_in, :220-248). Crucially, stage_* labels are never pulled back into Person.tags — they're hub-managed and would resurrect on the next push (:239-240).
If the org has opted into "website chats → leads" (org.settings["website_chat_to_lead"]), a brand-new chat contact also opens a new Lead (:290-292, 331-336).
app/routers/people.py:164-170, app/routers/messages.py:120). The Messages inbox reconcile is throttled to once / 5 min per org so a page load can't hammer the API (messages.py:74-93).app/routers/comms.py:284-285, 325-328), any lead change (sales router, above).sync_chatwoot_contacts() → backfill_all() (push) then pull_all() (pull) across every org (app/services/scheduler.py:159, 326-335).The hub's Messages → Crew tab is hub-native (CrewMessage rows are the source of truth). crew_chatwoot.py additionally pushes each crew message into Chatwoot so office staff who live in the Chatwoot agent app see field-worker and sub messages there too, and can reply from either place (app/services/crew_chatwoot.py:1-11).
mirror_crew_message() (crew_chatwoot.py:78-100):
CrewMessage to a Person (directly or via its Sub, :50-60).sync_person if missing, :87)._crew_inbox_id, :24-34). An API inbox created via the API starts with no members and is invisible to every agent, so _ensure_agents() assigns all account agents to it (:37-47, client.py:166-171).:63-75).:96-99). Office replies are prefixed with the author name.It's fired from the Messages router when an office user replies to crew (app/routers/messages.py:283-287).
Outbound SMS deliberately routes through Chatwoot's Twilio inbox (send_outbound_sms, client.py:187-222) so texts land in the unified inbox and still go out via Twilio — falling back to direct Twilio only if Chatwoot is down/unconfigured (app/services/comms.py:179-190, app/routers/messages.py:387-394). Numbers are normalized to strict E.164 before every call or Chatwoot 422s and the text silently fails (client.py:192-197).
Two paths turn a website chat into a hub lead:
Path A — the inbound webhook (app/routers/webhooks.py:357-459). Chatwoot POSTs message_created events to /api/webhooks/chatwoot, gated by chatwoot_webhook_secret via ?secret= or X-Chatwoot-Secret (:375-380). When the secret is unset the endpoint is inert (200, no-op) so it can't be abused as an open writer (:375-376). It always returns 200 — even on no-op — so Chatwoot doesn't retry-storm (:362). On a valid message_created:
Person by chatwoot_contact_id, else creates a lead-role person (:411-425).new Lead tagged source="Website chat", plus a LeadActivity quoting the first message (:427-440).chat Communication row and an attribution touchpoint (:442-457).Path B — the pull sync (§2d) picks up widget contacts on the scheduled/lazy reconcile even without the webhook.
⚠️ The webhook can't route on a pooled hub yet. It calls resolve_request_org() and no-ops (200) when the org is ambiguous rather than mis-attribute a chat to the wrong tenant — the per-org Chatwoot routing is explicitly deferred to "Phase 4" and not built (webhooks.py:401-408). So on a multi-tenant hub, webhook-driven capture is effectively off until that lands.
This is the load-bearing honest caveat.
Chatwoot is classified as a MANAGED provider — GoBuild runs it centrally, every org uses the shared account automatically, and the operator never enters a key (app/services/credentials.py:62-68). The inline comment claims self-hosted managed services are "data scoped per-org inside each" (credentials.py:63).
But the code does not enforce that scoping. The Chatwoot client reads the global get_settings().chatwoot_account_id and chatwoot_api_token directly on every call (client.py:25-33) — it never goes through the per-tenant CredentialResolver. There is a _GLOBAL_MAP entry for Chatwoot (credentials.py:89-91), but nothing resolves Chatwoot credentials per org; the actual client bypasses it entirely.
Net effect: all tenants' contacts, conversations, and labels land in ONE Chatwoot account and — as far as the code shows — one shared inbox namespace. There is:
crew_chatwoot.py:18).list_conversations / list_contacts (client.py:90-93, 119-122) — the Messages UI pulls the whole account's contacts and relies on the hub_person_id custom attribute to tie a conversation back to the right hub person (messages.py:59-71).So: is per-org inbox isolation enforced? On the evidence here, no — it is an open risk, not a shipped guarantee. If two Clients share one hub with Chatwoot configured, agent-side isolation appears to depend on Chatwoot's own account/inbox setup (operationally provisioned, not code-enforced) rather than on anything in this codebase. This should be verified against the live chat.gobuild.ca account structure before treating tenant separation as real. See Open questions and known-risks.
Every hub→Chatwoot path is best-effort and never raises into a request. This is stated in the module docstrings (chatwoot_sync.py:1-6, crew_chatwoot.py:1-11) and enforced with broad except:
sync_person returns None on any ChatwootError (chatwoot_sync.py:59-60).sync_lead wraps everything and db.rollback()s on any exception (:169-183); its sales-router callers also wrap it (sales.py:176-177 — "never block on a Chatwoot hiccup").pull_from_chatwoot breaks the page loop on error and rolls back per-contact (:298-299, 340-341).crew_chatwoot._ensure_agents swallows all exceptions (crew_chatwoot.py:46-47).messages.py:92-93).Trade-off: the hub never breaks because Chatwoot is down — but a failed sync is silent. There's no dead-letter queue or retry beyond the next scheduled/lazy reconcile, and no surfaced "this contact failed to sync" signal. A persistent misconfiguration (bad token, wrong account id) degrades to "Chatwoot quietly does nothing" rather than a visible error.
Don't conflate the two chat systems:
Communication rows on a Lead — no Chatwoot involved. (The Caddyfile WebSocket entries for Chatwoot are unrelated to Live, which is pure HTTP polling.)Both can capture a website visitor as a lead; they are separate stores that both feed the hub's People/Leads spine.
chatwoot_api_token + chatwoot_account_id (client.py:20-22). Confirm the live hub has them set and that the chat.gobuild.ca account has real agents/inboxes — otherwise the whole integration is dormant.client.py:25-33; §5). Verify against the live Chatwoot account: are there per-org inboxes, or do all tenants' conversations share one namespace? If shared, is that acceptable, and what stops one Client's agents from seeing another's contacts?webhooks.py:401-408). Is the webhook registered in the Chatwoot account at all, and is chatwoot_webhook_secret set? On a multi-tenant hub, is website-chat capture effectively dead?crew_chatwoot.py:18, 37-47). On a shared hub, do crew threads from different Clients land in the same inbox visible to all agents?website_chat_to_lead opt-in wired into the UI and used? Both the pull sync and the webhook branch on org.settings["website_chat_to_lead"] (chatwoot_sync.py:290-292, webhooks.py:428). Confirm there's a portal toggle setting it and that any org has it enabled.