User: "Schedule a meeting tomorrow at 2pm." Model: 2025-06-10T14:00. Developer: new Date(...).toISOString(). Calendar invite: 9:00 AM. Trust: gone.
LLMs output ISO-like datetimes without timezone suffixes. JavaScript interprets those as local time in the runtime environment — often UTC on the server — then converts to ISO UTC for storage. The user's wall clock and the stored instant diverge. We fixed this in meeting draft construction, not in the prompt alone.
The 2pm → 9am bug
The failure is subtle because some inputs work. Strings with Z or +05:30 parse correctly. Bare local datetimes do not. The scheduler UI displays what you store — if storage shifted the instant, every downstream API (Google Calendar create, Teams meeting) inherits the error.
Wall-clock local path
normalizeLocalDateTime regex-matches YYYY-MM-DDTHH:mm when no explicit timezone suffix is present. Those strings pass through unchanged as opaque wall-clock values:
const EXPLICIT_TZ = /[zZ]|[+-]\d{2}:?\d{2}$/
function normalizeLocalDateTime(raw: string): string | null {
if (EXPLICIT_TZ.test(raw.trim())) return null // use Date path
const match = raw.match(/^(d{4})-(d{2})-(d{2})T(d{2}):(d{2})/)
if (!match) return null
return `${year}-${month}-${day}T${hour}:${minute}`
}
// End time: add minutes via UTC component arithmetic — no zone shift
endTime = addMinutesToLocalDateTime(localStart, durationMinutes)The UI renders these as the user intended "2pm" without the server imposing its UTC offset.
Explicit offsets honoured
When the model includes Z or an offset, we use the Date parsing path and toISOString() — the model explicitly anchored the instant.
timeZone and currentTime from the browser. That helps the model — but server-side normalisation still must handle malformed output.Agent timezone vs draft builder
Split-brain risk: agent knows user TZ from session; meeting draft builder historically did not receive it on every path. Wall-clock preservation reduces dependence on server TZ but calendar create APIs still need an IANA zone at write time. Document which layer owns zone when pushing to Google or Microsoft.
Unparseable garbage falls back to now + one hour rounded — log when fallback fires; it hides model errors.
Sanitising model output
Models emit [aakarsh], quoted names, or comma-separated strings where arrays are expected. normalizeAttendeeText strips brackets and quotes before resolveAttendees runs — same names-to-emails pipeline as compose.
Related: name resolution · scheduling slots. Join the Kvika beta.