Natural language breaks the moment your compose form requires an email address. Users say "schedule with Kinshuok" or "email Sarah about the review." An AI agent passes those strings through. If your backend rejects anything without an @ symbol, the agent asks the user for an address — and the magic dies.
Name resolution has to happen server-side with the user's OAuth token, not in the model. We built a tiered directory in Kvika that resolves names to emails for compose, meeting prep, and typeahead pickers — without requiring admin-consented directory scopes on every tenant.
Why email addresses break the flow
OpenAPI schemas that mark the to field as format: email cause Bedrock action groups to reject valid name inputs before your code runs. Remove that constraint. Validate on the server after resolution.
The agent prompt explicitly says: pass raw names; Kvika resolves them. Compose and meeting prep share one function — resolveAttendees — so behaviour is consistent across email drafts and scheduler cards.
Three tiers of lookup
For each attendee string, the resolver runs in order:
1. Pass-through. If the string matches an email regex, use it directly.
2. Local directory. Merge colleagues from Microsoft Teams (up to five joined teams, members fetched with the user's token) plus contacts mined from calendar history. Score every candidate against the query.
3. Live org search. If local score is below threshold, query Graph: /users?$search="displayName:name" with ConsistencyLevel: eventual. If $search is denied, fall back to startswith(displayName, ...) and /me/people.
for (const query of attendees) {
if (isEmail(query)) return { email: query, matched: true }
const local = bestScore(localDirectory, query)
if (local.score >= 50) return { email: local.email, matched: true }
const org = await searchOrgDirectory(accessToken, query)
if (org) return { email: org.email, matched: true }
return { email: null, matched: false } // UI must let user fix
}Contacts from past meetings
Teams directory APIs fail in tenants without admin consent for User.ReadBasic.All. Calendar read often already exists. We build a secondary directory from the last 400 synced calendar events: extract attendee JSON, dedupe by email, keep display names.
These are people the user has actually met with — high precision for scheduling and compose. New hires with no meeting history will not appear here until the first invite; that is when org search or manual picker kicks in.
Fuzzy scoring and thresholds
Exact string match is not enough. "Sarah" must match "Sarah Chen." We tokenise display names and score:
Full string equal → 100. Exact token match → 90. Token prefix → 70. Substring → 50. Email local-part match → 95/75. Accept at ≥ 50; below that, return matched: false and show a person picker.
Common first names at score 50 can wrong-match. Low confidence must never auto-send email. The compose UI uses the same searchPeople typeahead as the meeting scheduler — parallel fetch of all three sources, dedupe by email, return top eight by score.
When resolution fails
No Microsoft integration. Teams and org search unavailable; calendar contacts may still work from Google-sourced events if attendees were stored on sync.
Expired client secret. Token refresh fails; directory calls return empty. Surface reconnect — not "person not found."
Agent emits bracket syntax. Models sometimes pass [aakarsh] as an attendee. Strip brackets and quotes before resolution in meeting draft normalisation.
Related: mixed-stack scheduling · findMeetingTimes fallback. Join the Kvika beta.