π’ Live & load-bearing. GoBuild Field (/field) is a separate, installable, touch-first surface over the same backend as the office Hub. It is where the crew lives on the jobsite: clock in, log the day, snap photos, report a hazard β all one-thumb, all offline-tolerant. The Hub is where the office lives.
Audience: field worker. This page describes what you see and do on your phone. If you manage the office side, see Jobs CC and Team & HR CC.
Two apps, one backend:
/portal/β¦) β the office desktop surface: money, contracts, proposals, the full CRM./field) β the crew's phone surface: capture, clock, logs, safety. Money-free by design for everyone below owner/PM.The split is not cosmetic. The field app has no static mount β it serves its own PWA manifest, service worker, and icon inline (app/routers/field.py:187-303) so it installs as its own standalone app with its own scope (/field/).
House terms: a Job is a project; a Client is the homeowner/customer. On the field app most crew never see the Client at all β that's the wall (below).
The field app is built to be reached from a phone with no keyboard patience. There are two front doors.
Instead of email + password, the crew taps a 4-digit PIN on a numpad. Source: app/routers/pin.py.
The PIN never travels as a primary credential off a trusted device. You set it once while already signed in, and it only unlocks devices that hold a signed keystone_device cookie (app/routers/pin.py:8-11, 55-58). The numpad screen (/portal/pin) renders in one of three shapes depending on that cookie (app/routers/pin.py:67-90):
| Shape | When | Behaviour |
|---|---|---|
| Personal | Device remembers one user | Their numpad directly β tap PIN, you're in |
| Roster (shared tablet) | Device remembers only the org | Shows the crew roster; tap your name, then your PIN |
| Untrusted device | Type your email, then your PIN β works anywhere |
Safety rails:
app/routers/pin.py:33-34, 114-120).app/routers/pin.py:450-453)./portal/pin/shared) trusts the org but forgets the single user, so a site trailer's tablet shows the whole roster (app/routers/pin.py:171-180).Admins (owner/PM) manage PINs from the Field Access page (/portal/team/pins, app/routers/pin.py:274-310): set, generate, bulk-generate for everyone missing one, and email/text the PIN via the org's own Resend/SMS creds (app/routers/pin.py:236-271). See Team & HR CC for the admin side.
The classic path still works β the login page offers a "π Sign in with a PIN" link that hands off to the numpad (templates/login.html:71).
If you open the office Hub on a phone, a dismissible banner offers to bounce you to the field app: "You're on a phone β open the Field App" β /field (templates/app.html:247-259). It's remembered via localStorage once dismissed. Marked π‘ because it's a nudge, not a hard redirect β a determined user can still run the desktop Hub on mobile.
Landing on /field renders Today (app/routers/field.py:309-331): a time-aware greeting, a command snapshot of active sites, your open shift (am I clocked in?), unread office messages, and β only if you're owner/PM β a money pulse (caps.can_see_money). Crew see the same screen with the money tile absent.
Core tabs (bottom nav): Today, Live view (a Leaflet map of located sites, app/routers/field.py:369-390), Clock, Capture, and More (logs, punch, safety, photos, HR, message-office, etc.).
Message the office (/field/office) threads crew messages straight into the Hub's Messages β Crew tab, and mirrors best-effort to Chatwoot (app/routers/field.py:425-485). Replies from the office show up here with unread counts.
The clock is the field app's daily heartbeat. Source: app/routers/field.py:1320-1427.
field.py:1371).lat/lng. The server computes the haversine distance to the Job's pin and stamps in_geofence = distance β€ (job.geofence_radius_m or 200) metres (field.py:1376-1380). The default fence is 200 m.in_geofence and distance_m are simply stored on the shift for the office to review. There is no hard stop on an out-of-fence clock-in.field.py:1381-1389) β this is how field hours flow into Job costing.client_time; when a queued offline punch drains later, the shift records when you actually clocked, not when the queue flushed β clamped to a sane window (no future, β€24h old) so a spoofed clock can't run wild (field.py:1343-1359).hours and best-effort syncs to OpenProject as a time entry (field.py:1400-1427).The daily log is the wedge β the core field-ops ritual. Screen: /field/logs (app/routers/field.py:2051-2095).
You pick a Job, type or dictate what happened, add weather, attach up to 8 photos. A log with neither notes nor a transcript is rejected (field.py:2087). Photos are persisted as job Documents (field.py:80-98).
Any dictation-enabled field on the field app carries a data-mic attribute; the base template wires a π€ button using the browser's Web Speech API (window.SpeechRecognition / webkitSpeechRecognition) and mirrors the transcript into a hidden voice_transcript field (templates/field/base.html:118-151). This is on the daily log and capture screens (templates/field/logs.html:20,28, templates/field/capture.html:18,48).
π‘ Reality check on "AI drafting." The
DailyLogmodel docstrings describe a voice β Deepgram transcript β DeepSeek structuring pipeline that fills astructuredJSON column (app/models/daily_log.py:1-4,app/routers/daily_logs.py:1-6). In the shipped field flow, dictation is browser-side Web Speech, not a Deepgram server call β Deepgram is a configurable per-tenant credential (app/services/credentials.py:57) but the field capture path stores the raw transcript and leavesstructuredfor the field capture structurer (app/services/field_capture.py) rather than a live Deepgram transcription. Treat the daily-log AI structuring as partially wired: raw capture π’, on-device dictation π’, server-side Deepgram transcription βͺ.
There are two daily-log surfaces in the codebase: the JSON API (app/routers/daily_logs.py, POST /api/daily-logs) used by richer clients, and the HTMX field form (/field/logs/create) the crew actually touches.
The field safety screen (/field/safety, app/routers/field.py:2195-2271) gives the crew two builder-facing rituals:
field.py:2219-2237).field.py:2240-2271). On file, the office is notified (_notify_office, field.py:1889-1903), and the report is flagged reportable based on severity.The office-side counterpart (app/routers/safety.py) adds a print-to-PDF incident report that names the correct reporting authority for the org's country/region β WSIB/WCB + OHS in Canada, OSHA in the US (app/routers/safety.py:2-6, 170-186). Incidents can be moved open β reported β closed from the Hub (safety.py:139-152).
app/routers/field.py:59-64).sees_all_jobs); crew see the jobs they're on. Every form-supplied job id is re-validated against your org before use (field.py:67-77)./field/photos), file punch-list items (/field/punch), annotate a plan/photo (/field/annotate), and submit a receipt (/field/receipt).Everything the field app shows is resolved by a single capability object, field_access.for_user() (app/services/field_access.py:73-93). The wall is the load-bearing rule: a subcontractor can never see or reach the Client.
| Capability | Owner | PM | Crew (field/office) |
Sub (/sub/{token}) |
|---|---|---|---|---|
can_capture (logs/photos/punch) |
β | β | β | β |
can_clock (geofenced clock) |
β | β | β | β |
can_see_money (contract value, margins, billing) |
β | β | β | β |
can_manage_team (assign, review, roster) |
β | β | β | β |
can_quote_client (draft/send proposals) |
β | β | β | β |
can_change_order (client-facing COs) |
β | β | β | β |
can_message_client (talk to homeowner) |
β | β | β | β |
Source: app/services/field_access.py:73-113. Notes:
can_see_money / can_quote_client (field.py:325, 523-531, 1439). Crew and subs get a redirect, never a number.field_access.py:84-88).Organization.settings["business_type"]); a solo contractor has owner power but team-management surfaces are hidden (field_access.py:28-44, 67-70)./sub/{token} (app/routers/sub_portal.py, prefix /sub) β capture and clock up to the contractor, zero client surface (field_access.py:96-113).Jobsites have dead zones; the field app is built to survive them.
/field/sw.js, field.py:201-230): network-first for same-origin pages with a cached fallback to /field; cache-first for CDN scripts/styles/fonts so reports and screens keep rendering offline. Cache version gobuild-field-v5./field/field.js, field.py:233-289): captures made with no signal are stored in IndexedDB and re-POSTed automatically on reconnect. This covers multipart photo forms (daily logs, punch, safety, warranty) and plain data-offline POSTs (task RSVP/status). A badge shows how many captures are queued; flushQueue() fires on the online event.field.py:224-228): the field service worker handles OS notifications; subscription/VAPID plumbing lives in app/routers/push.py (/portal/push/subscribe, /portal/push/vapid-public-key)./field/manifest.webmanifest, standalone display, portrait, auto-branded icon at /field/icon.svg (field.py:187-198, 292-303)./clock/{token} β riskSeparate from the authenticated field app, there is a no-login public time clock for subcontractor crews. Source: app/routers/timeclock.py.
Each Sub gets a clock_token (secrets.token_urlsafe(16)), texted to them as a link (app/routers/pin.py:295-303, 313-340). Anyone who opens /clock/{token} can:
timeclock.py:39-54),timeclock.py:57-117),π΄ The link is the credential β there is no PIN, OTP, or expiry. Anyone with the URL (a forwarded text, a shoulder-surf, a shared phone) can enumerate the org's active job names and file clock punches under that sub's identity. This is the same shape of risk as the invoice-portal token gap. See Known Risks and raise it there if it isn't already tracked. The authenticated field clock (/field/clock) does not share this exposure β it's behind a session.
Note: the same
clock_tokenalso fronts the full walled sub app at/sub/{token}(app/routers/sub_portal.py). The exposure is the token model itself, not any one screen.
| Concern | File |
|---|---|
Field app shell, PWA, all /field screens |
app/routers/field.py |
| PIN login (device-bound) + admin PIN dashboard | app/routers/pin.py |
Public tokenized clock (/clock/{token}) |
app/routers/timeclock.py |
| Daily-log JSON API | app/routers/daily_logs.py |
| Safety (toolbox + incidents, print-to-PDF) | app/routers/safety.py |
Walled subcontractor app (/sub/{token}) |
app/routers/sub_portal.py |
| Capability wall / role resolver | app/services/field_access.py |
| Talk-to-text (Web Speech API) | templates/field/base.html |
| DailyLog model (voice/structured columns) | app/models/daily_log.py |