Green Oak — onboarding

Customer #2 — Green Oak (owner Brad · PM Brooke). Priority 0: mine their past work orders into a vendor roster + a dispatch cheat sheet so Andrew can hand-run Green Oak tomorrow — given an issue + property, who to send. Full setup (directory data → load → wire the live agent → email routing) follows. Cold-start: no Reporting API, empty memory graph.

Priority 0 — past work orders → the dispatch cheat sheet — manual-ops MVP

Top priority: turn Green Oak's work-order history into a vendor roster + a (property → trade → vendor) cheat sheet. This one deliverable pays off even with nothing else wired — Andrew can hand-run Green Oak tomorrow: given an issue and a property, he knows who to dispatch, and texts Brooke manually. Live agent, email routing, tenants/owners all follow.

Why this is a cold-start — vs Vanessa (LAPM)

Two differences from Vanessa drive everything: an empty memory graph, and no AppFolio Reporting API. Vanessa's data came from the read-only Reporting API and her graph filled over months. Green Oak has neither, so baseline data must be crawled through the browser and the graph filled by the agent asking questions. Brad (Green Oak's PM) runs supervised until the roster and graph fill. Context: vault/decisions/2026-05-31-minimum-product-before-expansion.md.
SurfaceVanessa (LAPM)Green Oak
Memory graphSeeded from legacy data + months of learned beliefs / vendor prefsEmpty. Zero beliefs, no vendor-by-trade, no per-property quirks.
AppFolio readReporting API (read-only) + Playwright (send)Playwright session only — no Reporting API.
Baseline dataappfolio-sync (Reporting API)Sync is dead — must crawl the AppFolio UI.
WO intake + enrichEmail + Reporting API fills unit / clean descriptionEmail-only; enrich's API step degrades to the raw email.
Vendor suggestionsRoster + beliefs → confident picksEmpty roster → zero suggestions until crawled + learned.

Net: the two things that make the agent good for Vanessa — a populated roster/graph and API-enriched WOs — are absent on day one. That's the cold-start, and it's why Green Oak runs supervised while it fills.

Definition of done — LAPM parity

What "set up" looks like, measured from LAPM's live data. Green Oak's counts will differ; the shape (which fields are filled) is the target.

TableLAPM rowsFields that must come throughSkip
properties63name · address · city · state · postal_code · appfolio_property_id · appfolio_property_number
units381name (normalized) · appfolio_unit_id · property_id
tenants404name · email (~89%) · phone (~93%) · unit_idappfolio_tenant_id (0 at LAPM) · user_id (being dropped)
vendors34name · email · phone · trade · address · appfolio_vendor_id · preference_index
owners74name · email · phone · appfolio_owner_idpeople_id (0 at LAPM)
owner_properties69owner_id · property_id · workspace_id
Tenant dedup ≠ AppFolio id. LAPM never populated appfolio_tenant_id, so tenants are identified by the (unit_id, email, name) unique index. Capture the AppFolio tenant id if the UI exposes it (better idempotency), but parity doesn't require it.

Runtime — one server, all workspaces — decided 2026-06-01

One Node process on one Mac mini with one iMessage account already serves every customer. No per-customer instance — the code is multi-tenant by construction.

The ceiling is the single iMessage account, not compute. Split to a second Mac + Apple ID only when: a customer needs its own sender number (macOS runs one Apple ID per Messages.app), one account across many chats trips Apple's rate-limit / spam-flagging, or you want crash isolation (shared process + the flaky Messages dylib = one failure hits every customer). None bite at two customers.

A · Foundations — wiring, unblocks the rest

B · The crawler agent/scripts/appfolio-crawl.mjs

Disposable-but-reusable Playwright scraper. Phase 1 = crawl → review artifact; Phase 2 = --load. Prefer AppFolio's built-in CSV/Excel export per list page over DOM scraping; capture AppFolio ids from row-detail URLs as idempotency keys. Reuse scripts/appfolio-units-dump.mjs's deriveProposedName for unit naming. Priority by leverage: the WO-history crawl (Priority 0) comes first — one pass yields the vendor roster and the memory-graph seed. Then properties/units (resolve WO location + match email-intake addresses, which carry no API unit_id), then tenants and owners.

C · Load into Supabase — scoped to Green Oak, FK order

Idempotent upserts on (workspace_id, appfolio_*_id) so re-runs don't duplicate. Each step depends on the one above it.

D · Verify

E · Cold-start learning — fill the empty graph

The crawl seeds the roster and tables; the memory graph still starts empty. Two parts: confirm the learning pipeline fires on a fresh workspace, then let the agent ask instead of only learning passively.

Supervised is the learning ramp, not a workaround. Until the roster and graph fill, suggestions are weak by construction, so Brad runs supervised. Graduate Green Oak to autonomous when its send-without-edit / right-WO numbers match Vanessa's solo bar.

Open questions

F · UI — multi-workspace — how Andrew operates two customers

This is the surface Andrew runs Green Oak from. Today the control panel (ui/index.mjs + ui/page.html) shows one workspace via a prod/test toggle. The selector + per-workspace memory-graph view are how he reads Green Oak's seeded cheat sheet and picks a vendor — so this rides right behind P0, not "later."

Also shipped: per-workspace KPI strip (always shown, zeros for empty workspaces — /kpi?workspace=, needs a restart for real non-LAPM numbers); chat-poller shouldSessionize() made label-independent so every workspace sessionizes (was 'prod'||'test', would've dropped Green Oak); fixed switching bugs (stale toggle ref crashing init, KPI bleed, issues not clearing on an empty workspace).

G · Per-workspace send path — so Green Oak's agent can dispatch

Green Oak can ingest WOs now but can't send back into AppFolio until this is per-workspace. appfolio/runner.mjs hardwires VHOST=lapm.appfolio.com + one shared session. Needed before the agent acts on Green Oak (tenant/vendor texts via the AppFolio WO Texts widget); not needed while Andrew dispatches manually.