Microsoft Graph exposes /me/findMeetingTimes — pass attendees, a time window, a duration, and get back ranked slots where everyone is free. On paper it replaces the seven-reply email thread. In production it returns an empty array often enough that you need a Plan B before launch.
We call findMeetingTimes first. When meetingTimeSuggestions is empty, we log emptySuggestionsReason, fetch each attendee's calendar view with their own OAuth token, and brute-force a business-hours grid. Users still get slots. Support gets amethod: 'api' | 'manual' flag to debug which path fired.
What findMeetingTimes promises
Our request body includes minimumAttendeePercentage: 100 (everyone must be free), activityDomain: 'work', meetingDuration: 'PT30M', and a timeConstraint window. We ask for up to twenty candidates with suggestion reasons returned.
Attendees must be ChronoFlow users with connected Microsoft integrations — we look up each member's token by matching integration.accountId to the Teams member ID. External guests without a connected account are out of scope for this route today.
Why it returns nothing
Common causes we see in logs:
Attendee without a valid token. findMeetingTimes runs as the organiser. It cannot see calendars for members who have not connected Microsoft in Kvika — they drop out or produce empty free/busy.
Window too narrow or duration too long. A thirty-minute meeting in a fifteen-minute gap returns nothing. So does searching only one afternoon when everyone is booked.
Opaque Graph reasons. emptySuggestionsReason helps — log it alongside the request body on every empty response. Without it you are guessing.
?date=2025-10-14) constrains to 08:00–18:00 UTC on that day. Confirm timezone with the user in the UI — Graph defaults are not your user's wall clock.Manual grid fallback
When suggestions length is zero, we fetch /me/calendar/calendarView per attendee for the same window, collect busy/tentative/oof events, and iterate a grid:
// Skip weekends; 9–17 local-ish hours; 30-min slots
for (each day in window) {
for (hour = 9; hour < 17; hour++) {
slot = { start: day at hour, duration }
if (every attendee free at slot) suggestions.push(slot)
}
}Overlap detection compares slot boundaries against each event's start/end, respecting showAs — free events do not block. Manual results carry score: 'ManualAnalysis' so the UI can label them differently if needed.
This is expensive: N attendees × one calendarView call × hourly iteration. Acceptable as fallback, not as primary path. Cache attendee calendars within the request where possible.
What we still cannot do
Google attendees on this route. findMeetingTimes is Microsoft-native. Mixed Google + Microsoft free/busy needs a separate integration path.
User working hours. Fallback hardcodes business hours instead of reading MailboxSettings working hours per user.
Cross-org calendar read. Fallback uses each attendee's own token. If their calendar is empty due to permissions, manual analysis finds false "free" slots too.
Related: name resolution for attendees · mixed-stack scheduling. Join the Kvika beta.