Behavior — people search
PeopleSearchService.search(orgId, clerkId, query)
(apps/api/src/people-search/) is the one implementation. Spotter reaches it
through read.people_search and through the member/lead scopes of
read.search_anything, so both see exactly the same people. Result shape:
{ members: PeopleSearchMemberHit[], leads: PeopleSearchLeadHit[] }
(libs/shared/src/lib/schemas/people-search.schema.ts); the tool returns
{ items: [...members, ...leads], totalCount }.
Who sees what
| Caller | Members & staff | Leads |
|---|---|---|
| Owner / admin | all | all |
| Coach with a team-scope Sales grant | all | all |
| Coach with an own-scope Sales grant | all | only leads assigned to them |
| Coach without Sales access | all | none |
| Member | 403 | 403 |
Lead visibility is SalesWorkspaceAccessService.actor — the same rule Tasks
uses for lead-linked tasks.
Scope
No list filters.
- Members & staff: every
membershipsrow of the org, any role, any status. Removed memberships and deleted users are excluded. Managed children are found by name; they are never embedded. - Leads: every
organization_leadsrow of the org, archived included (archived: true).
Matching
A query shorter than 2 characters returns nothing; the tool rejects one longer than 100.
A person matches when ANY of:
- Text — every whitespace/comma term appears (case-insensitive) in some name or email column. “Levi Dana” finds Dana Levi across first/last name.
- Phone — the query is phone-shaped (digits, spaces,
+ ( ) . -) with at least 3 digits. A complete number reduces to its national significant number (050-123-4567and+972 50 123 4567→501234567); a partial one drops its trunk zeros (050-12→5012). The digits are matched inside the stored phone’s digits, so E.164 and legacy formatted phones both match. - Similar name —
PersonNameEmbeddingServicecandidates, the same retrieval the members list, leads list and inbox use. Asked only when the query has a letter and no@. If Voyage or the vector query fails, search degrades to text + phone.
Each hit carries match: phone, email, name or similar_name. Literal
matches rank first, similar_name after, by display name within each. At most
8 per group, chosen from the first 24 rows by name.
Spotter lanes
- Direct (no model): the conversation’s first message, trailing
?!.,;:dropped, is only an email (x@y, no spaces) or a complete, valid phone number starting with0or+(normalizePhone) →directPeopleQueryroutes it toread.people_search. Dates, amounts and partial numbers take the router. Later messages never take this lane: a number may be the answer to Spotter’s own question. - Router (one Haiku call): anything else goes to the existing T1 router;
a name or “find Dana” picks
read.people_search. - Card-only:
read.people_searchisquickAnswer: 'card', so the quick route skips the verbalizing call. The web card (PeopleResults) renders the whole answer: name, email · phone, role or lead status, “Cancelled”/other non-active member status, “Archived”, and “Similar name” on embedding hits. No hit: “No one matches that.” - Title: a card-only turn titles the conversation with the message as typed — no Haiku title call.
- Picking a row opens
/{lang}/dashboard/members/{membershipId}or/{lang}/dashboard/leads?lead={leadId}and closes the Spotter sheet. - Over budget:
AgentControllerlets a message through the budget gate whendirectPeopleQuerymatches it (nointent), and passesbudgetExceededto the orchestrator. That run only tries the direct lane — in any conversation, since no model can answer a follow-up anyway — and ends anything else withagent_budget_exceeded, calling no model. The usage footer’s at-cap banner adds “You can still look someone up: type just their phone number or email.”
Rollout
Unflagged: a new read-only lookup that changes no existing flow, and the API
enforces the same member/lead visibility as the existing lists.
read.search_anything now applies that visibility too: before, it returned
removed members and every lead to any staff member. Its people hits follow
the search’s rules — at most 8 members and 8 leads whatever limit asks, none
for a 1-character query, archived leads included with archived in the
snippet — and any people scope runs both groups.