---
title: 'Your LLM doesn''t know what day it is: date handling for AI tool calls'
date: 2026-09-03
excerpt: 'If you''re building AI features into your product, your model has no idea what day it is - and it will confidently guess. Here''s how to fix it.'
tags: [ai, llm, dev, typescript, tutorial]
canonical: https://mikebifulco.com/posts/make-tool-calls-date-aware
---
# 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](https://craftwork.com). **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:

```plaintext
🤖 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:

```ts
const system = `You are Craftwork's internal assistant.

Current date and time: 2026-08-04T09:14:00-06:00
Day of week: Tuesday
Time 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](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (`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](https://date-fns.org/) at Craftwork, paired with [`@date-fns/tz`](https://github.com/date-fns/tz) for time zone support:

```ts
import { TZDate } from '@date-fns/tz';
import {
  addDays,
  endOfMonth,
  format,
  getDay,
  startOfDay,
  startOfHour,
  startOfMonth,
  startOfWeek,
  subDays,
  subMonths,
  subWeeks,
  type Locale,
} from 'date-fns';
import { enUS } from 'date-fns/locale';

const iso = (d: Date) => format(d, 'yyyy-MM-dd');

// 'MDT' rather than 'GMT-6' - friendlier for the model, and for anyone
// reading your prompt logs later
const tzAbbreviation = (d: Date, timeZone: string) =>
  new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'short' })
    .formatToParts(d)
    .find((part) => part.type === 'timeZoneName')?.value;

export type NamedRange =
  | 'today'
  | 'yesterday'
  | 'last_weekend'
  | 'this_week'
  | 'last_week'
  | 'this_month'
  | 'last_month';

export type DateRange = { start: string; end: string; label: string };

export const toRange = (start: Date, end: Date): DateRange => ({
  start: iso(start),
  end: iso(end),
  // a human-readable label, for showing your work later on
  label:
    iso(start) === iso(end)
      ? format(start, 'EEEE, MMMM d')
      : `${format(start, 'EEEE, MMMM d')} through ${format(end, 'EEEE, MMMM d')}`,
});

// The single source of truth for what a named range means. The system prompt
// renders these, and the tool handler resolves against them - so there is
// exactly one implementation of "last weekend" in the codebase.
export const buildNamedRanges = (
  now: Date,
  locale: Locale = enUS
): Record<NamedRange, DateRange> => {
  // passing a locale makes startOfWeek respect that locale's first day of
  // the week - Sunday for en-US, Monday for en-GB, and so on
  const thisWeek = startOfWeek(now, { locale });
  const lastWeek = subWeeks(thisWeek, 1);
  const yesterday = subDays(now, 1);
  const lastMonth = subMonths(now, 1);

  // find the most recently *completed* Saturday.
  // date-fns getDay: 0 = Sunday ... 6 = Saturday
  const daysSinceSaturday = (getDay(now) + 1) % 7;
  const weekendInProgress = daysSinceSaturday <= 1;
  const lastWeekendStart = subWeeks(
    subDays(startOfDay(now), daysSinceSaturday),
    // if it's currently Sat or Sun, that weekend hasn't finished yet, so
    // "last weekend" is the one before it - a week further back
    weekendInProgress ? 1 : 0
  );

  return {
    today: toRange(now, now),
    yesterday: toRange(yesterday, yesterday),
    last_weekend: toRange(lastWeekendStart, addDays(lastWeekendStart, 1)),
    this_week: toRange(thisWeek, now),
    last_week: toRange(lastWeek, subDays(thisWeek, 1)),
    this_month: toRange(startOfMonth(now), now),
    last_month: toRange(startOfMonth(lastMonth), endOfMonth(lastMonth)),
  };
};

export const buildTemporalContext = (
  timeZone: string, // IANA, e.g. 'America/Denver'
  locale: Locale = enUS
) => {
  // TZDate carries the time zone with it, so every date-fns call below
  // stays in the user's zone instead of quietly falling back to the server's.
  // startOfHour, not the raw timestamp - rounding this matters for prompt
  // caching; see the companion post linked below
  const now = startOfHour(TZDate.tz(timeZone));

  const ranges = Object.entries(buildNamedRanges(now, locale))
    .map(([name, { start, end }]) => `- ${name}: ${start} to ${end}`)
    .join('\n');

  return `Current date and time: ${format(now, "yyyy-MM-dd'T'HH:mm:ssXXX")}
Day of week: ${format(now, 'EEEE')}
Time zone: ${timeZone} (${tzAbbreviation(now, timeZone)})
Locale: ${locale.code} - weeks start on ${format(startOfWeek(now, { locale }), 'EEEE')}

Pre-resolved date ranges. Use these names exactly, rather than calculating dates yourself:
${ranges}`;
};
```

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:

```ts
const searchConversations = {
  name: 'search_conversations',
  description:
    'Search customer conversation history. When the user describes a time ' +
    'period in words ("last weekend", "this month"), pass `relative_range` ' +
    'and let the system resolve it. Only use `start_date` and `end_date` when ' +
    'the user gives you explicit calendar dates.',
  input_schema: {
    type: 'object',
    properties: {
      query: {
        type: 'string',
        description: 'What to search conversation text for',
      },
      relative_range: {
        type: 'string',
        // exactly the names rendered into the system prompt above
        enum: [
          'today',
          'yesterday',
          'last_weekend',
          'this_week',
          'last_week',
          'this_month',
          'last_month',
        ],
        description: 'A named time period. Prefer this over explicit dates.',
      },
      start_date: {
        type: 'string',
        description: 'ISO 8601 calendar date, YYYY-MM-DD. Inclusive.',
      },
      end_date: {
        type: 'string',
        description: 'ISO 8601 calendar date, YYYY-MM-DD. Inclusive.',
      },
    },
    required: ['query'],
  },
};
```

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:

```ts
import { TZDate } from '@date-fns/tz';
import { format, startOfHour, subYears, type Locale } from 'date-fns';
import { enUS } from 'date-fns/locale';

import {
  buildNamedRanges,
  toRange,
  type DateRange,
  type NamedRange,
} from './temporal-context';

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;

// Midnight on a calendar date, *in the user's zone*.
// Watch out here: passing a bare string like '2026-08-01T00:00:00' to
// TZDate parses it in the server's zone first and then re-renders it,
// which can silently shift the date by a day. Pass components instead.
const atMidnight = (isoDate: string, timeZone: string) => {
  const [y, m, d] = isoDate.split('-').map(Number);
  const date = new TZDate(y, m - 1, d, timeZone);
  // rejects dates like 2026-02-31, which would otherwise roll forward
  return format(date, 'yyyy-MM-dd') === isoDate ? date : null;
};

// Same function that built the system prompt, so a name the model read up
// there always means the same thing down here.
const resolveNamedRange = (
  name: string,
  timeZone: string,
  locale: Locale = enUS
): DateRange => {
  const now = startOfHour(TZDate.tz(timeZone));
  const ranges = buildNamedRanges(now, locale);
  const match = ranges[name as NamedRange];

  // an unrecognized name means the model invented one. Throw rather than
  // fall through to "no date filter" - a loud error it can correct beats a
  // silent search of everything.
  if (!match) {
    throw new Error(
      `Unknown range "${name}". Use one of: ${Object.keys(ranges).join(', ')}`
    );
  }

  return match;
};

export const resolveRange = (
  input: { relative_range?: string; start_date?: string; end_date?: string },
  timeZone: string,
  locale: Locale = enUS
): DateRange | null => {
  // named range: we do the math, so it's right every time
  if (input.relative_range) {
    return resolveNamedRange(input.relative_range, timeZone, locale);
  }

  // explicit dates: trust, but verify
  if (input.start_date && input.end_date) {
    if (!ISO_DATE.test(input.start_date) || !ISO_DATE.test(input.end_date)) {
      throw new Error('Dates must be ISO 8601 calendar dates (YYYY-MM-DD)');
    }

    const start = atMidnight(input.start_date, timeZone);
    const end = atMidnight(input.end_date, timeZone);

    if (!start || !end) {
      throw new Error('Dates must be real calendar dates');
    }
    if (end < start) {
      throw new Error('end_date must be on or after start_date');
    }
    // a range starting in 2023 is a strong sign the model guessed
    if (start < subYears(TZDate.tz(timeZone), 2)) {
      throw new Error('Range starts more than 2 years ago - please confirm');
    }

    // same shape as a named range, so callers never have to care which
    // branch produced it
    return toRange(start, end);
  }

  return null; // no date filter - search everything
};
```

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](/posts/prompt-cache-hit-rate-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:

```ts
const range = resolveRange(input, timeZone, locale);
const results = await searchConversations(input.query, range);

return {
  resolved_range: range, // hand it back to the model
  results,
};
```

Then, in your system prompt:

```
When a search covers a date range, state the range you searched in plain
language before giving results. For example: "Searching conversations from
Saturday, 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:

1. **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.
2. **Pre-resolve common ranges** rather than asking the model to do calendar math it's bad at.
3. **Let your tools accept named periods** and resolve them in code, where you have a date library and tests.
4. **Show the resolved range to the user**, so wrong answers stop being silent.

And [keep an eye on that cache hit rate](/posts/prompt-cache-hit-rate-zero) 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.

## More from mikebifulco.com

- [Why your prompt cache hit rate is zero: the silent invalidators](https://mikebifulco.com/posts/prompt-cache-hit-rate-zero)
- [Unlocking A/B Testing with PostHog: Improving Newsletter Signups](https://mikebifulco.com/posts/ab-testing-with-posthog-to-fix-conversions)
- [Struggling with TypeScript: why not?](https://mikebifulco.com/newsletter/typescript-and-learning-hard-things)

---

Written by Mike Bifulco. Originally published at https://mikebifulco.com/posts/make-tool-calls-date-aware
