Firm-wide lookup by phone, email, or name. The flat contact index only carries last + first names, so phone and email searches require walking every case’s deep view record. This skill does that — finds the case where a phone or email lives, even when it’s buried on an applicant, employer, witness, or defense sub-record that the index doesn’t surface.
At the prompt:
aaicase> find by phone 310-555-0100
aaicase> look up phone (555) 555-0166
aaicase> case for 3105550100
aaicase> who has phone 555-0177
aaicase> find email doctor@example.com
aaicase> search by email j.sample@example.com
aaicase> find contact Sample
aaicase> find Dr. Sample across all cases
About 30–60 seconds later you have every case where the phone, email, or name appears — with the role made explicit. If a phone is the applicant’s, the result tags it applicant. If it’s the employer’s, employer. Defense, witness, carrier, medical provider, attorney — each gets the right label so the attorney sees not just “this case has the number” but “this case has the number on the applicant record.”
The flat /contacts/index endpoint returns minimal positional records: position 0 is last_name, position 1 is first_name. That’s it — no phone, no email, no address, no role. The pre-508 find contact skill searched only this index, so phone and email searches silently returned zero matches even when the value was clearly in Merus.
/contacts/index returned zero matches. The phone was sitting on a case applicant record at Applicant.Phone[0].digits — a deep field the index does not carry. Without the deep walk, the skill couldn’t find what was clearly there.
Search Contacts adds the second phase: after the flat index sweep, it walks /caseFiles/view/CASE_ID for every case in the firm and recursively traverses every nested string field. Phones, emails, addresses on applicants, employers, witnesses, defendants, carriers — anything stored as a string in the deep view record is searchable.
The new bin aai-contact-find.mjs runs the two-phase search:
node bin/aai-contact-find.mjs --phone "3105550100"
node bin/aai-contact-find.mjs --email "doctor@example.com"
node bin/aai-contact-find.mjs --name "Sample"
node bin/aai-contact-find.mjs --any "QUERY" # auto-detect shape
Auto-detection rules:
@ → emailPhase 1 fetches /contacts/index (single endpoint, ~2k records, in-memory match). Phase 2 fetches /caseFiles/view/CASE_ID for every case in concurrent batches of 10, traverses every nested string field, and reports per-case matches with role attribution.
The flat contact index returns positional records: last_name at index 0, first_name at index 1. Phase 1 normalizes the query (NFD-decompose, strip accents, lowercase, strip non-alphanumeric) and matches against the normalized name fields. Same normalizer used in find contact, lib/binding.mjs, and merus-search.mjs — shared across the codebase so name matching is consistent.
Phase 1 finishes in under 2 seconds. It catches name matches at the contact-record level (good for “find Dr. Sample” queries). It does NOT catch phone or email queries — those fields are absent from the index.
For every case in the firm, fetch the deep /caseFiles/view/CASE_ID record. Recursively traverse every nested string field via a generator that yields each string with its dotted path (e.g. Applicant.Phone[0].digits → "310-555-0100"). Normalize per query shape (digits-only for phone, lowercase for email, alphanumeric for name) and compare.
Concurrent batches of 10 keep total wall-clock to 30–60 seconds on a typical firm load (200–400 cases). Progress prints to stderr so the operator knows it’s working — not hung.
Phones get all non-digit characters stripped before comparison, on both the query and the stored value. So all of these match each other:
(310) 555-0100 (parens + space + dash)310.555.0100 (dots)+1-310-555-0100 (country code)3105550100 (digits only)310 555 0100 (spaces)The skill strips everything that isn’t a digit, so format mismatches between the way an attorney types a number and the way Merus stores it never block a match.
Emails get lowercased on both sides. Exact substring match on the normalized value, so doctor@example.com matches Doctor@Example.com and a search for the local-part alone (doctor) matches the email field but also matches anything else containing “doctor.”
Every Phase 2 match includes the field path that surfaced it. The skill maps each path to a human-readable role:
| Field path starts with | Role |
|---|---|
Applicant. | applicant |
Employer. or Employment. | employer |
Defendant. or contains Defendant | defendant |
Witness. or contains Witness | witness |
contains Carrier or Insurance | carrier |
contains Physician, Doctor, or QME | medical |
contains Attorney or Counsel | attorney |
CaseDetailsDisplayParty. | display-rollup |
| anything else | other |
The role tells the attorney WHY this case surfaced. A phone match tagged applicant means the attorney’s client has that number. A match tagged medical means a treating doctor or QME. A match tagged display-rollup is usually a duplicate signal (the same value appearing in two roll-up paths) — the per-case deduplication step keeps each (role, value) pair once.
The deep walk costs 30–60 seconds per lookup. A cache would mask that cost but risk surfacing stale data right when freshness matters most — an attorney looking up a phone right after their assistant logged it. The trade-off is intentional: honest cost, fresh data. The progress signal on stderr lets the attorney see the work happening.
Two lookups in a row cost two full walks. If that becomes painful in practice, the bin supports a --quiet flag (suppress progress to stderr) and a --json flag (structured output suitable for piping). It deliberately does not support a cache flag.
$ aai-contact-find --phone "3105550100"
[aai-contact-find] phase 1: /contacts/index sweep...
[aai-contact-find] phase 1: 2014 contacts loaded
[aai-contact-find] phase 1: 0 matches at index level
[aai-contact-find] phase 2: fetching case index...
[aai-contact-find] phase 2: 219 cases to walk
[aai-contact-find] phase 2: 50/219 cases walked, 0 hits so far
[aai-contact-find] phase 2: 100/219 cases walked, 0 hits so far
[aai-contact-find] phase 2: 150/219 cases walked, 0 hits so far
[aai-contact-find] phase 2: 219/219 cases walked, 2 hits so far
CONTACT LOOKUP — "3105550100" (detected as phone)
────────────────────────────────────────────────────────────
Phase 2 — /caseFiles/view deep walk (1 case of 219 scanned, 2 field hits):
Case #[CASE-#] Sample, Alex
applicant 310-555-0100 (Applicant.Phone[0].digits)
display-rollup 310-555-0100 (CaseDetailsDisplayParty.Phone[0].digits)
One case found. Both field paths reported — the operator sees the phone surfaces twice on the same case (the applicant record and its display rollup), each tagged with the right role. The case_file_id is the actionable handle: open it directly to see the full picture.
The deep walk covers structured fields in /caseFiles/view. It does NOT search:
A phone number mentioned only in a deposition transcript or an attorney’s note will not surface. For content-level search, fetch the relevant endpoint via merus-fetch and grep client-side. A future iteration may add --include-content; the current v1 is structured-field-only because content-level matches get noisy fast (phone numbers appear everywhere in transcripts).
First check: is the phone actually on a structured field? If it’s only in an activity description body, the deep walk won’t catch it. Try fetching the case directly: node bin/merus-fetch.mjs /caseFiles/view/CASE_ID | grep "DIGITS" — that’ll show the path. Second: try the digits-only form. The skill normalizes both sides, but if you typed a number with too few digits (5-digit area code by mistake), the 7-digit minimum filter rejects it.
The 30–60 second window is for a typical 200–400 case firm. For a 1000+ case firm, expect 2–3 minutes. Concurrent batches keep it bounded; the bottleneck is Merus latency, not parallelism. If your Merus instance is slow, the walk inherits that.
Common with carrier or defense-attorney numbers — one phone covers many cases. The output groups by case_file_id so each case’s context is clear. The role attribution helps: if the phone is tagged carrier across all matches, you’re looking at the carrier’s number; if it’s tagged applicant on one case but witness on another, you’re looking at someone who is the applicant in their own case and a witness in someone else’s.
Not in v1. Deferred because content-level matches get noisy. If you have a concrete use case, the right path is a follow-on pass with --include-content as an opt-in flag that sweeps /activities/index + selected upload metadata. Until then, fetch the specific endpoint and grep client-side.
aai-contact-find.mjs and inherits phone/email support automatically.Part of AAI for MerusCase — code-guarded AI case intelligence for California Workers’ Comp attorneys.