Onboarding — Technical Mapping
The decision tree made buildable: data model, state machine, the swarm as services, event catalog, flows, routing logic, and the production rigor. Grounded in the real precon_web schema — this extends the app, it doesn't reinvent it.
Perch · CRO cockpit · Jun 25 2026 · real exists in code · extend add fields to real · new greenfield
Architecture — capture to close
1 · Capture
Channel → Lead. PLG link / referral / search / outbound / bid-portal. Warm carries identity; cold carries only an email (or nothing yet). → Lead
↓
2 · Enrichment swarm
6 agents fan out. Cold = on the critical path; warm = pre-fetched before arrival. Each field confidence-scored. → Profile
↓
3 · Router (decision tree G0–G7)
Pure function of Profile. Picks track · persona · surface · next question · close motion. → RouteDecision
↓
4 · Onboarding session
State machine instance. One per lead/org. Drives confirm → value → activate, branching self-serve vs white-glove. → OnboardingSession
↓
5 · Surfaces + close
Real app surfaces (Jobs · Chat · Sarah · Team · Plan), seeded with the Profile's pursuits/network. Close = in-flow card or AE+MEDDPICC.
Cross-cutting (every layer)
- Event bus + audit — every transition + agent action logged
- Idempotency + retry — keyed per (lead, stage); backoff
- Feature flags — ships dark; defaults to today's 6-step form
- Kill switch — disable swarm → graceful manual fallback
- Confidence gate — assert if high, ask if low (~100% bar)
- Consent / PII — email + inbox access gated
Data model
TS-ish types. real already in precon_web · extend add columns to an existing table · new greenfield table.
Leadnew
iduuid
emailstring?null only in fully-anonymous cold pre-capture
namestring?
sourceLeadSource
warmthWarmth
identityKnownboolean
consent{inbox,marketing}
idempotencyKeystring
capturedAttimestamp
→ has one EnrichmentRun, one OnboardingSession; becomes an Org + User on activate
EnrichmentRunnew
iduuid
leadIduuid
modesync_critical | async_prefetchcold = sync; warm = prefetch before arrival
statusEnrichmentStatus
attemptsint
startedAt / completedAttimestamp
→ has many EnrichmentSignal; emits enrichment.*
EnrichmentSignalnew
iduuid
runIduuid
fieldProfileFieldcompany|orgType|sizeBand|revenue|acv|role|trade|sectors|serviceArea
valuejson
confidencefloat 0–1
agentAgentIdprovenance — which swarm agent
resolves into the materialized Profile
Profilenew view
leadIduuid
companystring
orgTypeOrgTypecontractor|sub_contractor|consumer — REAL enum
sizeBandSizeBand
revenueEst / acvEstmoney
role / personaPersona
tradeTradeValue?one of 34 (trades-data.ts) — subs only
sectors / serviceAreastring[]
confidenceRecord<field,float>
consumed by the Router (G0–G7) — read-only projection of signals
RouteDecisionnew
iduuid
sessionIduuid
trackTrack
personaPersona
primarySurfaceSurface
closeMotionCloseMotion
nextQuestionstring
gateTracejsonG0–G7 outcomes — auditable why
output of the pure router; recomputed if a signal changes
OnboardingSessionnew
iduuid
leadId / orgIduuid
stateSessionState
warmth / trackenum
versionintoptimistic lock — concurrent transitions
lastEventIduuid
the FSM instance; replaces the FE OnboardingStep store real
Pursuitnew → Job
iduuid
orgIduuid
name / value / bidDatemixed
sourcepermit|public_bid|news
statusfound|scoped|watching
seeds the real Job kanban; from ProjectFinder agent
TeamInvitereal extend
emailstringalready exists
rolestringalready exists — stop hardcoding "member"
surfacechat | field_opsEXTEND: field roles route to Field Ops
statuspending|accepted
extends the real TeamInvite{email,role}
TradeConfignew seed
tradeTradeValueTRADES[].value (trades-data.ts)
landerstring/{trade} → "{Label} OS"
takeoffFamilyTakeoffFamilycount|area|linear|volume|weight|systems
takeoffRecipestring[]what Sarah extracts
valueLeverstring
moneyFlagstringthe change-order / lead-time gotcha
seed data = the 34-row
trade-library;
composeQuestion() reads it. Position overrides live in the white-glove
BattlePack, not here.
Deal / BattlePacknew
id / orgIduuid
meddpiccMeddpiccRecordseeded by research, AE confirms
dossierjson
draftedOutreachstring
preRunTakeoffIduuidSarah, on their real pursuit
aeOwner / stageenum
white-glove only; reuses the Zwick MEDDPICC scorecard
Eventnew
iduuid
sessionIduuid
typeEventTypesee catalog
actoragent|user|ae|system
payloadjson
idempotencyKeystring
attimestamp
append-only log = audit trail + analytics + retry source-of-truth
Org / User / Subscriptionreal
Org.orgTypeOrgTypedrives AccountProfile features
User.roleOwner|Admin|Member
SubscriptionStripe21-day trial, plan_selection
existing tables; Lead → Org+User on activation
Enums
OrgType = contractor | sub_contractor | consumer | platform · REAL (account-profiles.ts)
BusinessType = general_contractor | sub_contractor | homeowner · REAL (onboarding-store.ts) — the onboarding-facing label of OrgType
Warmth = cold | warm | warmest · cold=anonymous · warm=email+name · warmest=+role
Track = self_serve | wg_mid | wg_enterprise
Persona = estimator | owner_exec | pm | field · field → Field Ops, not chat
CloseMotion = in_flow_card | ae_meddpicc
Surface = jobs | chat | sarah | company_brain | field_ops | analytics | network · REAL surfaces
SizeBand = micro | small | mid | large | enterprise · thresholds differ GC vs sub (see Routing)
TakeoffFamily =
count | area | linear | volume | weight | systems · the 34 trades collapse into 6 recipes — see trade-library
LeadSource = plg_invite | referral | search | ad | outbound | bid_portal | event
AgentId = domain_site | classifier | size_estimator | role_resolver | project_finder | network_seeder
EnrichmentStatus = queued | running | partial | complete | failed
State machine — OnboardingSession
Happy paths first, then the full transition table (incl. fallbacks/terminals). States map 1:1 to decision-tree gates.
Shared front-end
S0ENTRY
→
S1AWAIT_EMAIL
cold only
→
S2ENRICHING
→
S3CONFIRM
warm may skip
→
S4ROUTED
Self-serve tail ⚡
S5VALUE
→
S6NEXT_Q
→
S7VALUE_PROOF
Sarah
→
S8ACTIVATE
→
S9TRIAL
→
S10CLOSED_WON
card on file
White-glove tail 🤝
W5BATTLEPACK
agents
→
W6OUTREACH
SDR
→
W7DISCOVERY
AE
→
W8DEMO
→
W9SEAT
→
W10CLOSED_WON
MEDDPICC
Transition table
| From | Event | Guard | To |
| S0 ENTRY | lead.captured | warmth==cold | S1 AWAIT_EMAIL |
| S0 ENTRY | lead.identified | warmth>=warm | S2 ENRICHING (prefetched) |
| S1 AWAIT_EMAIL | email.submitted | — | S2 ENRICHING |
| S2 ENRICHING | enrichment.complete | conf>=θ all gating fields | S3 CONFIRM |
| S2 ENRICHING | enrichment.partial | some fields low-conf | S3 CONFIRM (ask only those) |
| S2 ENRICHING | enrichment.failed | retries exhausted / killed | → FALLBACK_FORM |
| S3 CONFIRM | onboarding.confirmed | warm & role known | S4 ROUTED (skip ask) |
| S3 CONFIRM | onboarding.role_captured | role was unknown | S4 ROUTED |
| S4 ROUTED | route.decided | track==self_serve | S5 VALUE |
| S4 ROUTED | route.decided | track==wg_* | W5 BATTLEPACK |
| S4 ROUTED | route.decided | orgType==consumer | → DISQUALIFIED (route to HO product) |
| S5→S8 | value.revealed · takeoff.completed | — | S9 TRIAL |
| S9 TRIAL | card.captured | post-wow trigger | S10 CLOSED_WON |
| S5..S9 (self-serve) | expansion.detected | seats/ACV cross threshold | → W5 (hand to white-glove) |
| W7 DISCOVERY | meddpicc.qualified | champion+EB confirmed | W8 DEMO |
| W9 SEAT | deal.closed_won | contract signed | W10 CLOSED_WON |
| any active | session.timeout | no event > TTL | → ABANDONED (re-engage queue) |
| FALLBACK_FORM | manual steps done | — | → today's 6-step flow → activate |
The swarm — six agents as services
| Agent | Input | Output (signals) | Sources | Mode / budget |
| domain_site | email domain | company, website, HQ | web | sync ~1s |
| classifier | website text | orgType, trade, sectors, project mix | site, LLM | sync ~2s |
| size_estimator | company | sizeBand, revenueEst, acvEst | aggregators, bonding | sync ~2s |
| role_resolver | email, name | role, persona | signature, LinkedIn | sync ~2s |
| project_finder | company, trade, geo | pursuits[] | permits, public bids, news | async — can lag |
| network_seeder | company | network[] (subs/GCs) | CRM, bid invites (consent) | async — can lag |
Orchestration: fan-out in parallel; the 4 sync agents gate S2→S3 (the confirm needs them), the 2 async agents can resolve after the user is already in the workspace (pipeline fills in live). Warm = all six run as async_prefetch before arrival, so none are on the critical path.
Event catalog
| Event | Emitted when | Consumers |
| lead.captured / lead.identified | entry (cold / warm) | enrichment, session, analytics |
| enrichment.requested | email known (or pre-capture) | swarm orchestrator |
| enrichment.agent.completed | each agent returns | signal store, confidence gate |
| profile.field_resolved | a field crosses confidence θ | router (may recompute) |
| enrichment.complete / .partial / .failed | run terminal | session FSM, retry engine |
| onboarding.confirmed / .role_captured | user taps confirm | session, router |
| route.decided | router runs | session, surfaces, sales |
| value.revealed / next_question.asked / takeoff.completed | self-serve value loop | analytics, close trigger |
| team.invited | invite sent | spawns a new warm Lead per invitee |
| trial.started / card.captured / account.activated | self-serve close | billing, CRM |
| deal.battlepack_ready / outreach.sent / meeting.booked / demo.delivered / deal.closed_won | white-glove | AE tooling, CRM, MEDDPICC |
| session.abandoned / session.handed_off | timeout / expansion | re-engage, sales queue |
Flows
Cold · self-serve (Squires, cold)
Visitorlands on GC lander → submits email email.submitted
Enrichment4 sync agents fan out; pipeline agents async enrichment.complete
Webrenders confirm from Profile (chips) → one tap onboarding.confirmed
RouterG0–G7 → {self_serve, owner_exec, jobs, in_flow_card} route.decided
SurfacesJobs seeded w/ pursuits + subs; Tom alert; the next question fires
Sarahscopes their real bid takeoff.completed → post-wow card ask card.captured
Warm · self-serve (Squires, warm)
Enrichmentpre-ran at PLG capture enrichment.complete (before arrival)
Visitorclicks personalized link lead.identified — no email/name ask
Routerrole known? → skip confirm → route.decided; role unknown → one tap
Surfaceslands directly on the pre-filled board → same value loop → close
White-glove (CVE)
Agentsdeep swarm → dossier + Sarah pre-takeoff + drafted outreach + MEDDPICC deal.battlepack_ready
SDRsends generated, personalized outbound outreach.sent → meeting.booked
AEinsight call (not discovery); confirms amber MEDDPICC cells meddpicc.qualified
Demopre-loaded with CVE's real pursuit demo.delivered
AEFDE seats the dept + migrates jobs → annual close deal.closed_won
Routing logic — the pure function
// Router: Profile -> RouteDecision. Pure, deterministic, re-runnable.
const θ = 0.85; // confidence threshold to assert vs ask
function route(p: Profile): RouteDecision {
// G2 — type (homeowner exits the B2B funnel)
if (p.orgType === "consumer") return disqualify("route_to_homeowner_product");
// G3 — track by size, ACV wins ties (open Q). Thresholds differ GC vs sub.
const band = sizeBand(p); // GC: <10M | 10–500M | 500M+ sub: <10M | 10–100M | 100M+
const track =
band === "micro_small" ? "self_serve"
: band === "mid" ? "wg_mid"
: "wg_enterprise";
if (disagree(p.revenueBand, p.acvBand)) prefer(p.acvBand); // ACV gate
// G4 — persona -> surface; field roles leave chat entirely
const persona = p.role ?? "ask"; // unknown -> the one allowed ask
const surface = { estimator:"jobs", owner_exec:"analytics",
pm:"network", field:"field_ops" }[persona];
// G5 — trade lander + Sarah recipe (subs only)
const trade = p.orgType === "sub_contractor" ? tradeConfig(p.trade) : null;
// G6/G7 — compose the question + pick the close
return {
track, persona, primarySurface: surface,
nextQuestion: composeQuestion(persona, band, trade),
closeMotion: track === "self_serve" ? "in_flow_card" : "ae_meddpicc",
gateTrace: trace(),
};
}
// The "smart" rule, applied everywhere we read a field:
const use = (f) => p.confidence[f] >= θ ? assert(p[f]) : ask(f); // never re-ask what's known
Cross-cutting concerns
Idempotency
Every Event + EnrichmentRun carries an idempotencyKey = (leadId, stage). Replays and at-least-once delivery are safe; the FSM uses an optimistic version lock so two concurrent transitions can't corrupt state.
Retry + backoff
Failed agents / outreach retry on 2m → 10m → 1h → 6h → abandoned. The Event log is the source of truth, so a retry cron replays from the last good event — same pattern as the Loops integration.
Feature flags — ship dark
Every gate + agent sits behind a flag, default off. With all off, onboarding is today's 6-step form. Cutover flips one flag at a time (e.g. classifier first), per cohort.
Kill switch + fallback
A global SWARM_ENABLED kill drops the whole flow to FALLBACK_FORM (the real organization_type → … → invitations steps). Onboarding never hard-depends on greenfield.
Confidence / human-in-loop
The ~100% bar: assert only at conf ≥ θ, else ask that one field. A wrong pre-fill is worse than a blank — and in white-glove a human stakes credibility on it, so the AE reviews before send.
Consent + PII
Email enrichment is fair game; inbox access (bid-invite seeding) is explicit opt-in. Retention + delete honored on the Lead before it converts to an Org.
Maps to the real app
| New concept | Real precon_web anchor | Change |
| Profile.orgType | OrgType (account-profiles.ts) | real reuse enum + AccountProfile features |
| Confirm step | organization_type · service_area · trades · market_sectors | collapse 4 backend steps → 1 confirm |
| OnboardingSession FSM | OnboardingStep store (zustand) | replace FE store w/ server FSM |
| route.persona → surface | AccountProfile.features + sidebar gating | new capture role; field → fieldOps |
| TeamInvite.surface | TeamInvite{email,role} | extend add surface; stop hardcoding "member" |
| Pursuit | Job (kanban) | new seeds real Jobs pre-signup |
| TradeConfig (×34) | trades-data.ts TRADES[] | seed the trade-library — 6 families, per-trade levers |
| Value proof | Sarah (precon_agents) | real tune recipe per type/trade |
| Close (self-serve) | plan_selection + Stripe 21-day trial | trigger move ask to post-wow |
| Deal / MEDDPICC | Zwick MEDDPICC scorecard | seed from research vs by hand |
| context_notes | Company Brain | auto pre-fill from enrichment |
Build order — ship dark, cut over one flag at a time
Phase 0
Spine, dark. Event log + OnboardingSession FSM behind a flag, defaulting to today's 6-step form. No user-visible change — just instrumentation + the state shell.
Phase 1
Cold MVP swarm. domain_site + classifier + size_estimator + role_resolver on the cold path → confirm-don't-ask replaces organization_type/service_area/trades/market_sectors. Biggest UX win; lowest risk (4 agents, all sync).
Phase 2
The value lever. project_finder + network_seeder → pre-filled Jobs pipeline + subs. Wire the role-conditioned next question + post-wow card trigger. This is the stickiness jump.
Phase 3
Warm / PLG. Identity passing on PLG links + async_prefetch enrichment before arrival. Each team invite becomes a warm lead.
Phase 4
White-glove. Battle pack + MEDDPICC seeding + AE tooling (outreach draft, pre-run takeoff, demo seeding). Enterprise contracting + security-review path.
This is the implementation spec for the decision tree; the Squires & CVE walkthroughs are its acceptance tests. Closes Brock's technical-mapping ask.