- Home
- Your LLM doesn't know what day it is: date handling for AI tool calls
Your LLM doesn't know what day it is: date handling for AI tool calls
Why LLMs don't know the current date
Your favorite LLM doesn't have any way of knowing what time it is. It has no access to the current date, and it doesn't know what day of the week it is. It also doesn't know what year it is, or even what decade it is.
It's one of those things that sounds obvious once you've heard it - but you need to tell your model the date and time in order for it to be able to make tool calls that are date-aware.
Every major chat product - ChatGPT, Claude, Gemini - injects the current date into the model's context before your message ever reaches it.
I ran into this while building an agent into our internal tools at Craftwork. If you're the one integrating the model, injecting the date is your job. If you skip it, you get some very strange behavior.
Hallucinated date ranges fail silently
Here's the scenario: at Craftwork, we have a variety of LLM tools for use internally - our sales and support teams will often ask our Assistant to search through previous conversations with customers, to provide better service. Things like "Which rooms are we painting ceilings in?" or "What colors is Joe Smith using on their project?" are pretty straightforward.
Another example of a common question might be: "Last weekend I got a call from a customer asking about Limewash. Can you tell me what their name was?"
Without telling the model the date, it has no idea what "last weekend" means. It doesn't know that it doesn't know, either - so it doesn't stop and ask. It picks something arbitrary, formats it into a perfectly valid ISO date range, and hands that to your tool with total confidence.
That's the part that should worry you. This isn't a crash, and it isn't an error you can catch. In old-timey programming lingo, this is called a "logical error" - everything appears to be working, but it is producing the wrong result... confidently.
Your tool receives a well-formed request, runs a real query, and returns an honest, empty result set. Then your assistant says:
1š¤ I couldn't find any conversations about Limewash.
To the salesperson reading that, this looks like a data problem. Maybe the call never got logged. Maybe the customer used different words. What it does not look like is a bug, so until someone notices, nobody files anything.
You might ship this, and never hear about it.
A confidently wrong answer is worse than no answer. In our case, the downside could be a support rep chasing a ghost for ten minutes, telling customers something wrong, or worse. If you're building something where dates carry real weight - scheduling, billing cycles, medication reminders, flight changes - a hallucinated date range stops being an annoyance and starts being a genuine hazard.
So, let's fix it.
Give your model a clock: put the date in your system prompt
The fix starts in your system prompt. Before any tool ever gets called, the model needs to know what "now" means:
1const system = `You are Craftwork's internal assistant.23Current date and time: 2026-08-04T09:14:00-06:004Day of week: Tuesday5Time zone: America/Denver (MDT)`;
That's the whole idea. It really is that simple, and it's the single highest-leverage thing you can do.
But there are a few details that will bite you, and they're the reason this post is longer than four lines.
A date alone isn't enough: time zone, day of week, and locale
Handing the model 2026-08-04 and calling it done will get you most of the way there and then quietly fail on the interesting cases. Three things travel with the date:
Time zone. This is important to get right. Our sales team is spread across the US, and datestamps are expressed in UTC. When a rep in Denver asks about "last weekend" at 9pm on a Sunday, UTC has already rolled over to Monday. Your model needs the user's IANA time zone (America/Denver), not your server's idea of the time.
Day of week. "Last weekend" means the most recently completed weekend - and which two dates that is depends entirely on the day you're asking. Ask on a Monday and it's the previous two days. Ask on a Sunday and it's seven to eight days back, because the weekend you're standing in isn't "last weekend," it's today and yesterday. Same two words, ranges a week apart, and the only thing separating them is a fact the model doesn't have.
The model can't reason about any of this unless it knows which day it is, and asking it to derive Tuesday from 2026-08-04 is asking for trouble.
Locale. This one surprised me. "Last week" depends on when the week starts, and that isn't universal: the week starts on Sunday in the US, Monday across most of Europe, and Saturday in much of the Middle East. If you're only shipping to one country you can safely ignore this. If you're not, a US-centric assumption baked into your date helpers will silently shift every "this week" query by a day for a chunk of your users.
Don't make the model do calendar math
Here's where I'll finish the thought I started earlier: if you've been building software for a long time, you'll be relieved to hear the answer is at least partly deterministic. And honestly, we should push it further than "partly."
Models are genuinely bad at calendar arithmetic. Ask one to work out the date range for "the weekend before last" and it will produce something that looks right, with abysmal accuracy for edge cases, like month boundaries, daylight saving transitions, and leap years. This is exactly the kind of work computers have been reliably good at since the 1970s. Don't outsource it to a language model.
So rather than giving the model a timestamp and hoping, give it the answers already worked out. We use date-fns at Craftwork, paired with @date-fns/tz for time zone support:
1import { TZDate } from '@date-fns/tz';2import {3addDays,4endOfMonth,5format,6getDay,7startOfDay,8startOfHour,9startOfMonth,10startOfWeek,11subDays,12subMonths,13subWeeks,14type Locale,15} from 'date-fns';16import { enUS } from 'date-fns/locale';1718const iso = (d: Date) => format(d, 'yyyy-MM-dd');1920// 'MDT' rather than 'GMT-6' - friendlier for the model, and for anyone21// reading your prompt logs later22const tzAbbreviation = (d: Date, timeZone: string) =>23new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'short' })24.formatToParts(d)25.find((part) => part.type === 'timeZoneName')?.value;2627export type NamedRange =28| 'today'29| 'yesterday'30| 'last_weekend'31| 'this_week'32| 'last_week'33| 'this_month'34| 'last_month';3536export type DateRange = { start: string; end: string; label: string };3738export const toRange = (start: Date, end: Date): DateRange => ({39start: iso(start),40end: iso(end),41// a human-readable label, for showing your work later on42label:43iso(start) === iso(end)44? format(start, 'EEEE, MMMM d')45: `${format(start, 'EEEE, MMMM d')} through ${format(end, 'EEEE, MMMM d')}`,46});4748// The single source of truth for what a named range means. The system prompt49// renders these, and the tool handler resolves against them - so there is50// exactly one implementation of "last weekend" in the codebase.51export const buildNamedRanges = (52now: Date,53locale: Locale = enUS54): Record<NamedRange, DateRange> => {55// passing a locale makes startOfWeek respect that locale's first day of56// the week - Sunday for en-US, Monday for en-GB, and so on57const thisWeek = startOfWeek(now, { locale });58const lastWeek = subWeeks(thisWeek, 1);59const yesterday = subDays(now, 1);60const lastMonth = subMonths(now, 1);6162// find the most recently *completed* Saturday.63// date-fns getDay: 0 = Sunday ... 6 = Saturday64const daysSinceSaturday = (getDay(now) + 1) % 7;65const weekendInProgress = daysSinceSaturday <= 1;66const lastWeekendStart = subWeeks(67subDays(startOfDay(now), daysSinceSaturday),68// if it's currently Sat or Sun, that weekend hasn't finished yet, so69// "last weekend" is the one before it - a week further back70weekendInProgress ? 1 : 071);7273return {74today: toRange(now, now),75yesterday: toRange(yesterday, yesterday),76last_weekend: toRange(lastWeekendStart, addDays(lastWeekendStart, 1)),77this_week: toRange(thisWeek, now),78last_week: toRange(lastWeek, subDays(thisWeek, 1)),79this_month: toRange(startOfMonth(now), now),80last_month: toRange(startOfMonth(lastMonth), endOfMonth(lastMonth)),81};82};8384export const buildTemporalContext = (85timeZone: string, // IANA, e.g. 'America/Denver'86locale: Locale = enUS87) => {88// TZDate carries the time zone with it, so every date-fns call below89// stays in the user's zone instead of quietly falling back to the server's.90// startOfHour, not the raw timestamp - rounding this matters for prompt91// caching; see the companion post linked below92const now = startOfHour(TZDate.tz(timeZone));9394const ranges = Object.entries(buildNamedRanges(now, locale))95.map(([name, { start, end }]) => `- ${name}: ${start} to ${end}`)96.join('\n');9798return `Current date and time: ${format(now, "yyyy-MM-dd'T'HH:mm:ssXXX")}99Day of week: ${format(now, 'EEEE')}100Time zone: ${timeZone} (${tzAbbreviation(now, timeZone)})101Locale: ${locale.code} - weeks start on ${format(startOfWeek(now, { locale }), 'EEEE')}102103Pre-resolved date ranges. Use these names exactly, rather than calculating dates yourself:104${ranges}`;105};
Now the model isn't calculating anything. It's looking things up, which is a task it's actually good at.
buildNamedRanges is deliberately a plain function of now, with no clock inside it. That makes it trivial to test - freeze a Tuesday, freeze a Sunday, freeze the Sunday that daylight saving ends, and assert on the ranges - and it means the next section can reuse it verbatim.
Design your tool schema so dates resolve in your code
The reference block gets you a long way, but there's a second layer worth adding: make it possible for the model to hand the date problem back to you.
Instead of a tool that only accepts explicit dates, accept a named period as well:
1const searchConversations = {2name: 'search_conversations',3description:4'Search customer conversation history. When the user describes a time ' +5'period in words ("last weekend", "this month"), pass `relative_range` ' +6'and let the system resolve it. Only use `start_date` and `end_date` when ' +7'the user gives you explicit calendar dates.',8input_schema: {9type: 'object',10properties: {11query: {12type: 'string',13description: 'What to search conversation text for',14},15relative_range: {16type: 'string',17// exactly the names rendered into the system prompt above18enum: [19'today',20'yesterday',21'last_weekend',22'this_week',23'last_week',24'this_month',25'last_month',26],27description: 'A named time period. Prefer this over explicit dates.',28},29start_date: {30type: 'string',31description: 'ISO 8601 calendar date, YYYY-MM-DD. Inclusive.',32},33end_date: {34type: 'string',35description: 'ISO 8601 calendar date, YYYY-MM-DD. Inclusive.',36},37},38required: ['query'],39},40};
Three things to call out here, because they all matter more than they look:
The enum is the same vocabulary as the system prompt. buildNamedRanges renders last_weekend into the prompt and last_weekend is what the schema accepts - one list of names, generated from one piece of code. When those two drift apart, the model reads this week upstairs, sends past_7_days downstairs, and you get a range nobody wrote a test for.
The tool description is where you steer behavior. "Prefer this over explicit dates" in the description does more work than the same sentence buried in your system prompt. Be specific about when to reach for each parameter, not just what each one is.
And spell out the format. ISO 8601 calendar date, YYYY-MM-DD leaves no room for the model to hand you 08/04/2026 and let you find out at runtime which of those numbers was the month.
Then resolve it in your handler, where you have a real date library and real tests:
1import { TZDate } from '@date-fns/tz';2import { format, startOfHour, subYears, type Locale } from 'date-fns';3import { enUS } from 'date-fns/locale';45import {6buildNamedRanges,7toRange,8type DateRange,9type NamedRange,10} from './temporal-context';1112const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;1314// Midnight on a calendar date, *in the user's zone*.15// Watch out here: passing a bare string like '2026-08-01T00:00:00' to16// TZDate parses it in the server's zone first and then re-renders it,17// which can silently shift the date by a day. Pass components instead.18const atMidnight = (isoDate: string, timeZone: string) => {19const [y, m, d] = isoDate.split('-').map(Number);20const date = new TZDate(y, m - 1, d, timeZone);21// rejects dates like 2026-02-31, which would otherwise roll forward22return format(date, 'yyyy-MM-dd') === isoDate ? date : null;23};2425// Same function that built the system prompt, so a name the model read up26// there always means the same thing down here.27const resolveNamedRange = (28name: string,29timeZone: string,30locale: Locale = enUS31): DateRange => {32const now = startOfHour(TZDate.tz(timeZone));33const ranges = buildNamedRanges(now, locale);34const match = ranges[name as NamedRange];3536// an unrecognized name means the model invented one. Throw rather than37// fall through to "no date filter" - a loud error it can correct beats a38// silent search of everything.39if (!match) {40throw new Error(41`Unknown range "${name}". Use one of: ${Object.keys(ranges).join(', ')}`42);43}4445return match;46};4748export const resolveRange = (49input: { relative_range?: string; start_date?: string; end_date?: string },50timeZone: string,51locale: Locale = enUS52): DateRange | null => {53// named range: we do the math, so it's right every time54if (input.relative_range) {55return resolveNamedRange(input.relative_range, timeZone, locale);56}5758// explicit dates: trust, but verify59if (input.start_date && input.end_date) {60if (!ISO_DATE.test(input.start_date) || !ISO_DATE.test(input.end_date)) {61throw new Error('Dates must be ISO 8601 calendar dates (YYYY-MM-DD)');62}6364const start = atMidnight(input.start_date, timeZone);65const end = atMidnight(input.end_date, timeZone);6667if (!start || !end) {68throw new Error('Dates must be real calendar dates');69}70if (end < start) {71throw new Error('end_date must be on or after start_date');72}73// a range starting in 2023 is a strong sign the model guessed74if (start < subYears(TZDate.tz(timeZone), 2)) {75throw new Error('Range starts more than 2 years ago - please confirm');76}7778// same shape as a named range, so callers never have to care which79// branch produced it80return toRange(start, end);81}8283return null; // no date filter - search everything84};
Those validation errors are worth the keystrokes. When a tool throws, most frameworks hand the error text back to the model, which gets a chance to correct itself instead of silently querying 2023.
One caching trap before you ship this
There's a bill-shaped bug hiding in everything above, and I want to flag it here even though it deserves its own post.
You're about to put a timestamp into your system prompt. Prompt caching is a prefix match - providers reuse cached work only up to the first byte that differs - so a raw new Date().toISOString() sitting above ten thousand tokens of instructions will drop your cache hit rate to exactly zero. Permanently. With no error and no warning.
Two habits keep you out of it:
Round the timestamp to the coarsest precision your feature can tolerate. That's why buildTemporalContext above calls startOfHour instead of using the raw clock - hourly is plenty for resolving "last weekend," and it means you invalidate once an hour instead of on every request.
Put the volatile block last, after your cache breakpoint, so your expensive stable instructions cache cleanly in front of it.
I shipped this bug and only caught it on an invoice. The full write-up - ordering, TTLs, the multi-turn wrinkle, and how to actually verify a cache hit - is here: why your prompt cache hit rate is zero.
Close the loop: show the user the date range you searched
One more thing, and it's the cheapest reliability win in this whole post.
Have your tool return the range it resolved, and have your assistant say it out loud:
1const range = resolveRange(input, timeZone, locale);2const results = await searchConversations(input.query, range);34return {5resolved_range: range, // hand it back to the model6results,7};
Then, in your system prompt:
1When a search covers a date range, state the range you searched in plain2language before giving results. For example: "Searching conversations from3Saturday, August 1 through Sunday, August 2..."
Now your rep sees:
Searching conversations from Saturday, August 1 through Sunday, August 2... I found one call about Limewash, from George Whitfield.
If the range is wrong, a human catches it instantly - because a human knows what "last weekend" means and does not need to be told. You've converted a silent failure into an obvious one, which is the entire game.
It also covers the cases where your interpretation is defensible but still isn't the one the person had in mind. "Last week" is the clearest example: our code resolves it to a calendar week, and plenty of people say "last week" meaning the last seven days. Both readings are reasonable, and no amount of prompt engineering picks the right one every time. Showing your work lets the person who does know just say "no, go back further."
Wrapping up
The whole fix comes down to four moves:
- Inject the current date, time zone, day of week, and locale into your system prompt. If you're integrating the model, nobody else is doing this for you.
- Pre-resolve common ranges rather than asking the model to do calendar math it's bad at.
- Let your tools accept named periods and resolve them in code, where you have a date library and tests.
- Show the resolved range to the user, so wrong answers stop being silent.
And keep an eye on that cache hit rate while you do it - rounding that timestamp is the difference between a cached prompt and a surprising bill.
None of this is complicated. It's just easy to skip, because nothing fails loudly when you do - and "nothing fails loudly" is a genuinely bad property for software that people are trusting with real questions.
In this article
- Why LLMs don't know the current date
- Hallucinated date ranges fail silently
- Give your model a clock: put the date in your system prompt
- A date alone isn't enough: time zone, day of week, and locale
- Don't make the model do calendar math
- Design your tool schema so dates resolve in your code
- One caching trap before you ship this
- Close the loop: show the user the date range you searched
- Wrapping up
