DeskCaller
Craft

Monday the 20th Was a Wednesday

Aneeq Iftikhar
Aneeq Iftikhar · Senior Software Engineer, DeskCaller
· 18 min read
Voice agent booking the wrong date, cover reading 'Monday the 20th was a Wednesday' above 'why voice agents book the wrong day', beside a glowing blue line-art month grid where the 20th is struck through in red and a different date is lit in blue

An agent tells the caller their appointment is Monday the 20th, then books Wednesday the 20th. Both halves came out of the same sentence. Only one of them reached the calendar, and the caller has no way of knowing which.

That mismatch is the whole problem in miniature. The agent was not confused about the caller's request, it was doing calendar arithmetic in a language model and shipping whichever half of the answer the tool call happened to carry. Three separate faults produce that symptom, they need three different fixes, and on the phone they sound exactly the same.

This is the booking leg of the pipeline we run at DeskCaller. What follows is the resolution order a phrase travels through, from the caller's mouth to the calendar row, with the real variable names, the real defaults, and the places where the default is wrong for a UK business.

Key takeaways

  • A weekday and a date that disagree in the agent's readback is the diagnostic signature: the model is computing, not looking up.
  • Vapi, Retell, n8n and Google Calendar each have a different default timezone, and not one of them defaults to Europe/London.
  • Tokenizers split dates into meaningless fragments, and date fragmentation predicts accuracy drops of up to ten points on temporal reasoning tasks.
  • "Next Tuesday" has two readings that native speakers genuinely disagree about, so no prompt can resolve it and only a confirmation turn can.
  • The fix is structural: the model returns the caller's words, a deterministic resolver returns the date.
  • Commit with a client-generated event ID so a retry cannot double-book.

The bug announces itself in the readback

Listen for a weekday and a date that do not match each other. When an agent says "Monday the 20th" and the 20th is a Wednesday, the model has computed both values independently and got one wrong, which means the date in the tool call is unanchored too. Callers rarely catch it. The transcript always shows it.

Three signatures are worth telling apart, and each names its own fault. Dates in a year that has already passed mean no reference instant reached the prompt at all, so the model fell back on the era of its training data. Dates that are correct but shifted by exactly one day mean a timezone boundary. Dates that are plausible but a week out mean the phrase itself was ambiguous and the model picked the reading the caller did not intend.

Same caller complaint, three different faults. Tuning the prompt fixes the first, and does nothing whatsoever for the other two.

Four components, four different default timezones

A voice booking stack usually spans four systems, and each one has its own idea of what time it is when you configure nothing. None of these defaults is Europe/London.

Component Default if you set nothing How you actually set it
Vapi {{now}}, {{date}}, {{time}} UTC LiquidJS date filter with an IANA name
Retell {{current_time}} America/Los_Angeles {{current_time_Europe/London}}
ElevenLabs system__time Whatever system__timezone is set to, which you must pass Pass system__timezone per conversation
n8n $now America/New_York GENERIC_TIMEZONE env var, or per-workflow settings
Google Calendar event The calendar's own timezone start.timeZone, or an offset inside start.dateTime

Read that table as a list of five independent chances to be wrong on one booking. The n8n default is the one that catches UK teams hardest, because it is documented, silent, and five hours out: n8n's own docs give the default as America/New_York, overridable with GENERIC_TIMEZONE or in workflow settings.

The Vapi row is the subtle one. UTC is a defensible default and it agrees with Europe/London for roughly five months of the year, which is precisely what makes it dangerous. It fails only during British Summer Time, and only for part of the day.

The model cannot do the arithmetic, and the tokenizer is part of the reason

Injecting today's date raises the floor. It does not make the model a calendar. Date arithmetic fails inside language models for a reason that sits below the reasoning layer entirely, in how the text is cut into tokens before any thinking happens.

"Date Fragments: A Hidden Bottleneck of Tokenization for Temporal Reasoning" (Bhatia, Peyrard and Zhao, 2025) measures this directly. Modern BPE tokenizers split 20250312 into 202, 503, 12, fragments that cross the boundaries between year, month and day and carry no calendar meaning. The paper introduces a date fragmentation ratio, releases DateAugBench with 6,500 examples across three temporal tasks, and reports accuracy drops of up to ten points on uncommon dates.

PRIMETIME (Gaere and Wangenheim, 2025) attacks the same question from the other end, testing datetime parsing and datetime arithmetic in isolation rather than as one aggregate score. Its finding: "the primitives themselves prove individually unreliable, with per-primitive accuracy ranging from near-zero to perfect across models and prompting conditions."

An operation whose accuracy ranges from near-zero to perfect depending on prompt phrasing is not an operation you put in the path of a booking.

"Next Tuesday" is ambiguous before any software touches it

Two readings of "next Tuesday" are in live use among native English speakers, and no standard picks a winner. One group treats the current week as a unit, so "this Tuesday" is in this week and "next Tuesday" is in the following one. The other group means the next Tuesday that arrives, whichever week it falls in. Usage also varies by region.

Said on a Wednesday, those two readings are six days apart. There is no prompt engineering that resolves this, because the caller's sentence does not contain the answer. A parser that picks one is not being correct, it is guessing on the caller's behalf and hiding the guess.

"Tomorrow afternoon" has the same shape in a different dimension. The date is computable and the hour is not, because "afternoon" is a business decision about your slots, not a fact about the calendar.

Both cases resolve the same way: offer a specific slot and get a yes. A voice agent has one advantage over every booking form ever built, which is that it can ask. Use it.

What each platform gives you to inject the reference instant

Every platform exposes the current instant. They disagree on syntax and, more consequentially, on default zone.

Platform Date and time variables Notes
Vapi {{now}}, {{date}}, {{time}}, {{month}}, {{day}}, {{year}} All UTC. For any other zone use {{"now" | date: "%A, %B %d, %Y, %I:%M %p", "Europe/London"}}
Retell {{current_time}}, {{current_hour}}, {{current_calendar}} Default America/Los_Angeles; append the zone as {{current_time_Europe/London}}
ElevenLabs system__time, system__time_utc, system__timezone system__time renders human-readable, system__time_utc renders ISO

Retell's {{current_calendar}} is the most interesting variable in that table, because it changes the shape of the task. It renders a fortnight of dates with "(Today)" marked on the right one, so the model reads a date off a list instead of computing it. Worth reproducing by hand on platforms that do not ship it. It renders with HTML <br /> separators, so keep it in the prompt and out of anything the agent reads aloud.

A prompt header that gives the model every anchor it needs, and takes the arithmetic away from it:

Today is {{"now" | date: "%A %d %B %Y", "Europe/London"}}.
The current time is {{"now" | date: "%H:%M", "Europe/London"}} in Europe/London.

DATE RULES
- Never calculate a calendar date yourself. Never state a date you have calculated.
- To book, call resolve_slot with the caller's own words in spoken_phrase.
- If the caller names a weekday, pass it in weekday_said, exactly as they said it.
- If the caller says "next" plus a weekday, ask which one they mean before calling
  the tool: "Do you mean this coming Tuesday the eighth, or the Tuesday after?"
- Read back the weekday and the date together before confirming, always.
- If resolve_slot returns needs_clarification, ask the question it gives you.

Never let the model emit a date

Take dates out of the model's output entirely. The tool takes the caller's phrase, not a computed date, and a deterministic resolver on the other side turns the phrase into an instant. The model's job is transcription and intent, both things it is good at.

{
  "name": "resolve_slot",
  "description": "Resolve a spoken date and time phrase into a bookable slot. Pass the caller's words verbatim. Never pass a date you calculated.",
  "parameters": {
    "type": "object",
    "properties": {
      "spoken_phrase": {
        "type": "string",
        "description": "The caller's own words, e.g. 'next Tuesday afternoon', 'a week from Thursday', 'the 8th'"
      },
      "weekday_said": {
        "type": "string",
        "enum": ["monday","tuesday","wednesday","thursday","friday","saturday","sunday","none"],
        "description": "The weekday the caller named, if any. Used as a checksum against the resolved date."
      },
      "part_of_day": {
        "type": "string",
        "enum": ["morning","afternoon","evening","specific_time","none"]
      },
      "specific_time_said": {
        "type": "string",
        "description": "Only if part_of_day is specific_time. The caller's words, e.g. 'half two', 'quarter past nine'"
      }
    },
    "required": ["spoken_phrase", "weekday_said", "part_of_day"]
  }
}

weekday_said is the part that earns its keep. When the caller names a weekday and a date, you get a free consistency check: resolve the phrase, compare the resolved date's weekday against what the caller said, and if they disagree, one of them was misheard. That is a question for the caller, not a coin toss. It is the same class of guardrail as refusing to state a price the agent cannot verify, covered in the guardrails guide.

Resolving the phrase deterministically

Pass an explicit reference instant and force the parser forwards. Both are opt-in, and the defaults will book appointments in the past.

chrono-node documents the trap plainly. Parsing "Friday" with a reference date of Saturday 25 August 2012 returns Friday 24 August, the day before, because the nearest Friday is behind you. With forwardDate: true it returns Friday 31 August. A booking parser running the default configuration will cheerfully resolve a Saturday caller's "Friday" to yesterday.

// n8n Code node. Resolve outside the model, in the business's own zone.
const { DateTime } = require('luxon');
const chrono = require('chrono-node');

const ZONE = 'Europe/London';
const ref = DateTime.now().setZone(ZONE);

const parsed = chrono.parse(
  $json.spoken_phrase,
  { instant: ref.toJSDate(), timezone: ZONE },
  { forwardDate: true }
);

if (!parsed.length) {
  return [{ json: { needs_clarification: 'no_date_found' } }];
}

const slot = DateTime.fromJSDate(parsed[0].start.date(), { zone: ZONE });

// Checksum: did the caller's weekday survive the resolution?
const said = $json.weekday_said;
if (said !== 'none' && slot.toFormat('cccc').toLowerCase() !== said) {
  return [{ json: {
    needs_clarification: 'weekday_mismatch',
    resolved: slot.toISO(),
    resolved_weekday: slot.toFormat('cccc'),
    weekday_said: said,
  }}];
}

// A phrase the parser resolved into the past is a resolution failure, not a booking.
if (slot < ref) {
  return [{ json: { needs_clarification: 'resolved_in_past', resolved: slot.toISO() } }];
}

return [{ json: { resolved: slot.toISO(), resolved_weekday: slot.toFormat('cccc') } }];

Two habits worth keeping from that snippet. Always name the zone rather than relying on the host's idea of local time, and treat a past resolution as a failure that asks a question, never as a date to book.

Two operational notes. A self-hosted n8n Code node will not require an external package unless it is allowed through NODE_FUNCTION_ALLOW_EXTERNAL, so this runs in a small service beside n8n on managed instances. And the parser is not above scrutiny either: chrono issue #553, open against v2.7.0, reports the timezone parsing reference combined with forwardDate applying the wrong offset across a daylight-saving transition. Assert the resolved value against a fixture rather than trusting any single library across a clock change.

The n8n leg, where the offset survives and the timezone does not

A payload from the n8n community forum shows the failure exactly, reported by a UK user with a Europe/London calendar and a Europe/London workflow:

"start": { "dateTime": "2025-07-19T21:00:00+01:00", "timeZone": "America/New_York" }

The offset is right and the timeZone field is somebody else's continent. The thread's answer was GENERIC_TIMEZONE, unset and therefore America/New_York. A separate open issue against the node itself, n8n #14411 on version 1.85.4, reports that the event is created in America/New_York even when the dateTime carries a +01:00 offset and timeZone is explicitly provided, and that calling the Google API through the HTTP Request node instead works correctly.

Google's own contract is narrower than most integrations assume. start.dateTime needs an RFC3339 value, and "a time zone offset is required unless a time zone is explicitly specified in timeZone". For single events timeZone is optional; for recurring events it is required, because it defines the zone the recurrence expands in. What the reference does not define is which field wins when both are present and disagree.

So do not send both in disagreement. Send a full offset datetime with no timeZone, or a naive local datetime with timeZone set. Pick one shape and assert it in a test.

The hour that makes it look intermittent

Here is why this bug gets described as random. Vapi's {{now}} is UTC, and the UK is UTC+1 from the last Sunday in March to the last Sunday in October. In 2026 that is 1am on Sunday 29 March to 2am on Sunday 25 October; in 2027, 28 March to 31 October.

During those seven months, between midnight and 1am local, UTC is still on the previous date. A caller at half past midnight on Tuesday 16 June 2026 is talking to an agent whose clock reads 23:30 on Monday 15 June. Ask for "tomorrow" and the agent resolves 16 June, which is today. One hour a day, seven months a year, late-night calls only. Every daytime test passes.

The transitions themselves add two more cases that a naive resolver gets wrong in the other direction. At the spring change, 01:30 on 29 March 2026 does not exist as a local time, because 01:00 becomes 02:00. At the autumn change, 01:30 on 25 October 2026 happens twice, so a local datetime with no offset does not identify one instant. Luxon will tell you both facts if you ask it, and silently pick something if you do not.

Booking differs more by vertical than the mechanics suggest

The resolver is shared. What counts as a valid slot is not, and that is where the industry playbooks diverge.

A dental practice books recalls months out, so "the first week of March" is a normal request and the ambiguity runs to weeks rather than days, on top of the clinical limits in the dental guide. A restaurant books days out, in service windows, where "Friday, late" is a table at nine and not a date problem at all, as in the restaurant prompt. Emergency trades barely use relative dates: "as soon as you can" is a dispatch decision, and the escalation path in the plumber playbook matters far more than the calendar.

Resolve dates once, centrally. Keep the slot rules per industry, next to the prompt that knows what the business actually sells.

Confirm, then commit idempotently

Read the weekday and the date back together, in spoken form, before writing anything:

"That's Tuesday the eighth of September, at half past two in the afternoon.
 Shall I book that in?"

Both halves matter. The weekday is what lets a caller catch a misheard date, and the date is what lets them catch a misheard weekday. This is one of the few places where the readback should not be interruptible, for the reason set out in the barge-in guide: a caller talking over a confirmation leaves you unsure what they agreed to.

Then check the diary before you promise it. Google's freebusy.query takes timeMin and timeMax as RFC3339 values, with an optional timeZone that defaults to UTC and affects the response only.

Then make the write safe to retry. Google Calendar lets you supply your own event ID on events.insert, and the create-events guide says using one "prevents duplicate event creation if the operation fails at some point after it is successfully executed in the Calendar backend". The rules: base32hex characters only, meaning lowercase a to v and digits 0 to 9, between 5 and 1,024 characters, unique per calendar. Google recommends an RFC4122 UUID, so strip the hyphens, since a hyphen is not in that character set. Derive the ID from the call and the resolved slot rather than from a random value, and the retry after a dropped connection lands on the same row instead of a second appointment.

Take the word "prevents" with a caveat, because Google's API reference contradicts its own guide on the same field: "Due to the globally distributed nature of the system, we cannot guarantee that ID collisions will be detected at event creation time." A detected collision does return a clean 409, so a derived ID is still the right default. It is a strong reduction in duplicates rather than a guarantee, which is why the write also needs reconciling afterwards. That, and the rest of what breaks after the date is correct, is in It said booked, the calendar says nothing.

The test set

Relative-date handling is testable in the same way any pure function is: freeze a reference instant, run the phrase, assert the output. The cases that break in production are the ones nobody thinks to dial, so they belong in a fixture rather than in a manual test call. All reference instants below are Europe/London.

Phrase Reference instant Correct behaviour
"the 8th" Wed 2 Sep 2026, 10:00 Tue 8 Sep 2026. Control case, must never fail
"next Tuesday" Wed 2 Sep 2026, 10:00 Ambiguous: 8 Sep or 15 Sep. Must ask
"next Tuesday" Tue 8 Sep 2026, 10:00 15 Sep 2026. Both readings agree, no question needed
"Friday" Sat 5 Sep 2026, 10:00 Fri 11 Sep. Default chrono returns 4 Sep, which is yesterday
"tomorrow" Tue 16 Jun 2026, 00:30 Wed 17 Jun. A UTC clock says 16 Jun
"tomorrow afternoon" Wed 2 Sep 2026, 10:00 Date resolves, hour does not. Offer a slot
"a week from Thursday" Wed 2 Sep 2026, 10:00 Ambiguous: 10 Sep or 17 Sep. Must ask
"this weekend" Sun 6 Sep 2026, 10:00 Ambiguous: today, or 12 and 13 Sep. Must ask
"the 31st" Wed 2 Sep 2026, 10:00 September has 30 days. Must ask, must not silently roll to 1 Oct
"the 29th of February" Sat 28 Feb 2026, 10:00 2026 is not a leap year. Must ask
"half one on the 29th of March" any, 2026 01:30 does not exist on 29 Mar 2026. Must reject or ask
"half one on the 25th of October" any, 2026 01:30 occurs twice on 25 Oct 2026. Must disambiguate the offset
"Tuesday the 9th" Wed 2 Sep 2026, 10:00 9 Sep is a Wednesday. Weekday checksum fires, must ask

Run the last row against any booking agent you are evaluating, including your own. An agent that books Wednesday the 9th without mentioning that the caller said Tuesday is not resolving dates, it is picking whichever half arrived last. Wiring this table into an automated suite belongs with the rest of your pre-launch checks in the evaluation guide.

Frequently asked questions

I put the current date in the system prompt and it still books the wrong day. Why? Because the reference instant was only the first of three faults. Check the zone of the value you injected, since Vapi's {{now}} is UTC and Retell's {{current_time}} is America/Los_Angeles. Then check whether the model is still calculating the date rather than passing the caller's phrase to a resolver. Then check whether the phrase was ambiguous in the first place, in which case the right output is a question, not a date.

Should the agent ever say a date it worked out itself? No. Have it read back a date the resolver returned, which is a different thing. If the value the agent speaks and the value the tool receives come from different places, they will disagree eventually, and the caller only hears one of them.

Is {{current_calendar}} style date injection better than a resolver? It is a good supplement and not a replacement. A rendered fortnight removes the day-of-week arithmetic for near-term dates, which is where most bookings sit. It does nothing for "the first week of March", for genuine ambiguity, or for the timezone of the write itself.

How do I stop a dropped call from creating two appointments? Supply your own event ID on events.insert, derived deterministically from the call ID and the resolved slot. Google documents client-generated IDs as the way to avoid duplicates when a request fails after the backend has already applied it. Keep to lowercase a to v and digits, and strip the hyphens if you generate a UUID.

Does any of this get easier with a better model? The arithmetic gets more reliable and the ambiguity does not move at all, because "next Tuesday" is under-specified in English rather than hard to compute. The confirmation turn is permanent. The resolver is what stops a fluent guess reaching the diary.

Booking is one leg of the call. What happens when the caller wants a person instead is the transfer guide, and the response-time cost of every extra tool hop in this chain is in the latency budget.

If you want a phone agent that already resolves dates outside the model and reads them back before it writes, see DeskCaller for dental practices.

🎙️ Talk to Our AI Agent

Try it now - it's live!