π’ Real, end-to-end, and load-bearing. Keeping the Client (homeowner) informed is one of GoBuild's most complete wedges. Two mechanisms carry it: the ClientUpdate feed (builder-composed progress posts, with photos, drafted by AI from the job's real state) and the two-way message thread on the Client portal. Both are wired from compose β publish β the homeowner actually seeing it. The one soft spot is notification delivery β whether the homeowner learns a new update exists depends entirely on which send button the builder pressed. That asymmetry is the thing to understand on this page.
Audience: office + field. The composing side is for owners/PMs at the desk (Messages inbox, office composer) and on site (field composer). The receiving side is the homeowner's portal. House terms: a Job is a project; a Client is the homeowner/customer.
Cross-links: Portals Β· Daily logs Β· GoBuild Live
There are two channels from builder to Client, and they are deliberately different things:
| Channel | Direction | Store | Where the Client sees it |
|---|---|---|---|
| ClientUpdate | builder β Client (broadcast) | ClientUpdate (app/models/daily_log.py:42) |
Portal feed card + optional email/SMS |
| Message | builder β Client (conversation) | Message (app/models/messaging.py:17) |
Portal thread |
A ClientUpdate is a published post ("here's how your project is going"). A Message is a chat turn. They share the same portal page but are separate tables and separate flows.
ClientUpdate (app/models/daily_log.py:42-57) is a hub-owned, Job-scoped progress note:
ClientUpdate
ββ job_id β the Job (required, CASCADE)
ββ ai_draft β the AI's original draft (kept for the edited-vs-sent signal)
ββ edited_body β what actually went live (Subject: line stripped)
ββ photo_document_ids β JSON list of Document ids (job photos shown with the post)
ββ published_at β set once β the update is LIVE on the portal feed
ββ emailed_at β stamped if the builder emailed it
ββ texted_at β stamped if the builder texted it
The single gate for visibility is published_at. A row with published_at IS NULL is a draft nobody sees; once it's stamped, it appears on the Client's portal.
The Client portal (no-login, token-scoped β see Portals) builds one time-ordered feed across content types at app/routers/client_portal.py:205-289. Only published updates are pulled:
select(ClientUpdate).where(ClientUpdate.job_id == job.id,
ClientUpdate.published_at.is_not(None))
.order_by(ClientUpdate.published_at.desc())
Each update card renders its edited_body plus its attached photos, resolved via client_updates.photos_for (app/services/client_updates.py:52). A neat touch: photos attached to an update are dropped from the generic "new photos" feed entry so the homeowner never sees the same shot twice (client_portal.py:271-279). The feed also interleaves change orders, invoices, and loose photos β the ClientUpdate is one lane in a unified timeline.
There are three surfaces that create a ClientUpdate, all built on two shared services: the AI composer ai_assistant.compose_client_update and the publish helper client_updates.publish_to_feed.
compose_client_update (app/services/ai_assistant.py:1749-1832) is not a generic "write me an email" prompt. It reads the Job's actual state since the last update:
ScheduleItems.percentage_done._last_update_at (ai_assistant.py:1740), i.e. the last published_at. If nothing's new, it falls back to the most recent few logs for substance.structured dict and free-text weather.This is the daily-log β ClientUpdate bridge: the daily logs the crew files become the raw material the homeowner-facing update is grounded in. The system prompt forces warmth-but-honesty, 3 short paragraphs, an invite to view photos, and a Subject: line the publish step later strips. Output is always human-editable before it goes anywhere.
βͺ Caveat inherited from daily logs: the composer reads
log.structuredfor issues, but daily logs notes thatstructuredis almost never populated. So the "open issues" section of an AI update is usually empty in practice β the update leans on progress %, photo counts, and raw log notes instead.
Desk flow, owner/PM, under /portal:
POST /portal/ai/compose (app/portal/router.py:5093-5143) β drafts via compose_client_update, persists a ClientUpdate row (draft only), pre-fills the recipient (main contact's email β else the linked Client), and offers the whole job photo gallery with photos-since-last-update pre-checked.| Button | Route | Feed | SMS | |
|---|---|---|---|---|
| Send (email) | POST /portal/client-updates/{id}/send (router.py:5146) |
β | β
(branded client_update template) |
β |
| Text | POST /portal/client-updates/{id}/text (router.py:5271) |
β | β | β (+ portal link appended) |
| Post | POST /portal/client-updates/{id}/post (router.py:5314) |
β | β | β |
All three call publish_to_feed (or set published_at inline) so the portal feed always gets the update first β even if email is disabled or the SMS flakes (router.py:5179-5183). Email goes out via outbound_email.send(..., "client_update", ...); if the org has email turned off, the UI honestly says so and still confirms the portal post (router.py:5204-5206).
On-site flow, owner/PM only β subs are walled out by caps.can_message_client (app/routers/field.py:1114-1170). The crew can:
POST /field/captures/{id}/client-update, field.py:1071), which even degrades to the capture's own summary if the AI is down.GET /field/jobs/{id}/client-update), edit, attach photos from the gallery, and send.π‘ The field send is feed-only.
POST /field/jobs/{id}/client-update/send(field.py:1142-1170) calls onlypublish_to_feedβ no email, no SMS. An update sent from the phone lands on the portal silently. See notification delivery.
client_updates.publish_to_feed (app/services/client_updates.py:62) is the single choke point: it strips the Subject: line, writes edited_body, ties the validated photo ids to the update, and stamps published_at (idempotently β never re-stamps an already-live update). Photo ids are sanitized by normalize_photo_ids (client_updates.py:28), which drops junk and cross-job ids so an update can never smuggle another job's photos onto this homeowner's feed.
Important scope note: the Messages inbox is not sales-specific and not client-only. It is one unified command center that merges every person's threads β leads, customers, contacts, and crew/subs β split into two tabs: People and Crew.
GET /portal/messages (app/routers/messages.py:96-179) renders a two-pane inbox. conversations.build_threads (app/services/conversations.py:213-249) merges three stores into one per-Person timeline:
Message β homeowner β contractor (Job-scoped portal chat)CrewMessage β sub/employee β contractor (the walled-off crew channel)Communication β email/SMS log to a contact/lead (incl. inbound SMS)The tab split is purely by role: crew persons β Crew tab, everyone else β People tab (messages.py:132-133). Opening a thread marks the homeowner's inbound messages read via conversations.mark_read (conversations.py:261), which drives the unread badge.
Because a sub and a client are different Person rows, a crew person's thread only ever contains crew chat + their own SMS/email, and a client's only ever contains portal chat + their SMS/email (conversations.py:1-13). CrewMessage is deliberately a separate table from Message β that separation is the wall (app/models/messaging.py:29-34): a sub can only ever post to CrewMessage, which never surfaces on the Client portal. See GoBuild Live for how the field/crew side is scoped.
Don't confuse the Messages inbox (messages.py) with the Contacts hub (app/routers/comms.py). Contacts is the People directory + a compose-and-send-one-off-email/SMS surface (comms.py:462 send), with its own AI draft + branded live preview. Both are office/PM (field role excluded, comms.py:29-33), both log to Communication, and both feed the same unified timeline. Contacts is "look someone up and fire off a message"; Messages is "the running conversation."
The homeowner posts from the public portal at POST /client/{token}/messages (app/routers/client_portal.py:415-441). It writes a Message with sender="client" and fires an office notification:
notify.notify_new_message(db, org, who=f"{author} Β· {job.name}",
preview=body.strip(), link=f"/portal/messages?job={job.id}")
notify_new_message (app/services/notify.py:153-157) fans out to every owner/PM/office user across their enabled channels β in-app bell, email, SMS, push (notify.py:125-143, 160-175). So when the Client writes, the builder reliably finds out.
From the inbox, POST /portal/messages/send (messages.py:233-290) routes to the Client on the resolved chat channel (a Job-scoped Message with sender="contractor"), or SMS/email if chosen. The reply shows up in the portal thread on the homeowner's next visit.
π‘ Asymmetry: a builder's chat reply writes a
Messagerow and does not notify the homeowner β no email, no SMS, no push. Only if the builder picks the SMS or email channel does the homeowner get pinged off-portal. (Contrast the Clientβbuilder direction, which always notifies the office.)
How does the homeowner learn a new update exists? Only if the builder chose a push-out path. There is no automatic "you have a new update" notification to the homeowner.
| Event | Homeowner notified off-portal? |
|---|---|
| Update sent via Send (email) | β Email (if org email enabled) |
| Update sent via Text | β SMS |
| Update sent via Post (feed-only) | β Silent β portal only |
| Update sent from the field app | β Silent β portal only (field.py:1142) |
| Builder chat reply | β Silent (unless sent as SMS/email) |
The reasons this is structural, not a bug:
notify._push, notify.py:174) is keyed to staff User rows only; homeowners have none.Practical consequence: an update posted feed-only (Post button, or any field-app send) is invisible until the homeowner happens to open the portal. If reliable delivery matters, the builder must use Send (email) or Text. This is worth flagging to any org that assumes "posting an update" also tells the customer.
Is the ClientUpdate feed actually used? The office composer, field composer, and three send paths are all wired, but is there production/demo evidence of orgs publishing updates β or is this a well-built surface that sees little real traffic? No usage counter or "last update sent" nudge surfaced in the code reviewed.
Notification delivery to the homeowner β is feed-only-silence intended? Two of the send paths (Post, and every field-app send) publish with zero homeowner notification. Is that a deliberate "portal is pull" design, or a gap where field-composed updates should at least email/text like the desk's Send button does?
Should a builder's chat reply notify the homeowner? Clientβbuilder always notifies the office; builderβClient chat is silent. Is the homeowner expected to poll the portal for replies, and is that acceptable for a conversation channel?
Message inbox scope β is the People+Crew merge the intended model? The inbox unifies clients, leads, contacts, and crew into one command center. Confirm this is the desired mental model (vs. a client-only view), since the "shared inbox" framing surprises people who expect a sales-style separation.
The structured dependency in AI updates. compose_client_update mines log.structured for open issues, but daily logs reports structured is almost never populated. Does that leave homeowner updates systematically silent on issues/delays β and is that a risk (a delay the Client should hear about never makes the update)?
Email/SMS deliverability gating. send_client_update degrades gracefully when org email is off ("posted to portal, email is off"). But is there any surfacing to the builder that the homeowner therefore wasn't actually told β or does the builder walk away believing the Client was informed?