# Natural Language Calendar

URL: /docs/experiments/natural-language-calendar
> Turn plain English into a calendar invite with Workers AI JSON mode - parse events and return .ics-ready structured data
Tags: ai, workers-ai, calendar, nlp, ics, structured-output, json-mode
Bindings: AI
Markdown: /llms.mdx/experiments/natural-language-calendar/content.md



<Callout type="warning">
  This is an experimental Worker. Use it as a starting point for your own projects.
</Callout>

Type "Lunch with [sam@example.com](mailto:sam@example.com) next Tue 1pm at Blue Bottle" and get back a real calendar invite. [Workers AI JSON mode](https://developers.cloudflare.com/workers-ai/features/json-mode/) extracts the event. Code then validates it, converts it to UTC, and builds an RFC 5545 `.ics` file plus Google Calendar and Outlook links.

## Features [#features]

* Resolves relative dates ("tomorrow", "next Tue") with a prompt that lists today and the next 14 dates with weekdays
* Time zone from the request body, else the visitor's `request.cf.timezone`, else UTC
* DST-safe local-to-UTC conversion with `Intl.DateTimeFormat`
* RFC 5545 generator: CRLF, 75-octet folding, TEXT escaping, `UID`, `DTSTAMP`
* `GET /event.ics` downloads directly into Calendar apps
* Attendee emails are kept only if they appear in the input text

## API Reference [#api-reference]

### POST /event [#post-event]

Parse text into an event.

<TypeTable
  type="{
  text: {
    description: &#x22;Event description in plain English. 1-500 characters.&#x22;,
    type: &#x22;string&#x22;,
    required: true,
  },
  timezone: {
    description:
      &#x22;IANA time zone, e.g. `America/New_York`. Defaults to `request.cf.timezone`, then `UTC`.&#x22;,
    type: &#x22;string&#x22;,
    required: false,
  },
  now: {
    description:
      &#x22;ISO 8601 reference time used to resolve relative dates. Defaults to the current time.&#x22;,
    type: &#x22;string&#x22;,
    required: false,
  },
}"
/>

#### Example Request [#example-request]

```bash
curl -X POST "https://your-worker.workers.dev/event" \
  -H "Content-Type: application/json" \
  -d '{"text":"Lunch with sam@example.com next Tue 1pm at Blue Bottle","timezone":"America/New_York"}'
```

#### Example Response [#example-response]

```json
{
  "event": {
    "title": "Lunch",
    "allDay": false,
    "timezone": "America/New_York",
    "start": "2026-10-06T13:00:00",
    "end": "2026-10-06T14:00:00",
    "startUtc": "2026-10-06T17:00:00.000Z",
    "endUtc": "2026-10-06T18:00:00.000Z",
    "durationMinutes": 60,
    "location": "Blue Bottle",
    "description": null,
    "attendees": ["sam@example.com"]
  },
  "ics": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n...",
  "googleCalendarUrl": "https://calendar.google.com/calendar/render?action=TEMPLATE&text=Lunch&dates=20261006T170000Z%2F20261006T180000Z&...",
  "outlookUrl": "https://outlook.live.com/calendar/0/deeplink/compose?path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&..."
}
```

**`start`*&#x2A; / &#x2A;*`end`** are local wall times in `timezone`. For all-day events they are dates, `end` is exclusive, and `startUtc` / `endUtc` are `null`.

### GET /event.ics [#get-eventics]

Same inputs as query parameters: `text`, `timezone`, `now`. Returns `text/calendar; charset=utf-8` with `Content-Disposition: attachment; filename=event.ics`.

```bash
curl -OJ "https://your-worker.workers.dev/event.ics?text=Team%20offsite%20Oct%2012-13%20in%20Lisbon&timezone=Europe/Lisbon"
```

```text
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//cloudflare-experiments//natural-language-calendar//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:ee85e37b-1c18-40ef-8b56-6a9a68b9fcd4@natural-language-calendar.workers.
 dev
DTSTAMP:20261001T165159Z
DTSTART;VALUE=DATE:20261012
DTEND;VALUE=DATE:20261014
SUMMARY:Team offsite
LOCATION:Lisbon
END:VEVENT
END:VCALENDAR
```

#### Error Responses [#error-responses]

```json
{
  "error": "Couldn't find a date in that text. Try rephrasing, e.g. 'Lunch with Sam next Tuesday at 1pm'",
  "code": "PARSE_ERROR"
}
```

* `400` - `INVALID_TEXT` (missing or over 500 characters), `INVALID_TIMEZONE`, `INVALID_NOW`, `INVALID_BODY` (`POST` body is not JSON), `PARSE_ERROR` (model output has no usable date)
* `502` - `AI_ERROR` (model call failed)

## Implementation Details [#implementation-details]

### Why UTC instead of TZID [#why-utc-instead-of-tzid]

A `DTSTART;TZID=America/New_York:...` line is only strictly valid with a matching `VTIMEZONE` block. Instead, `src/lib/ics.ts` writes timed events in UTC (`DTSTART:20261006T170000Z`), so no `VTIMEZONE` block is needed and every client shows the correct instant in the viewer's own zone. All-day events use `DTSTART;VALUE=DATE:` with an exclusive `DTEND`.

### Local time to UTC [#local-time-to-utc]

The model returns wall-clock time (`2026-10-06T13:00`). `zonedTimeToUtc` in `src/lib/timezone.ts` guesses the offset with `Intl.DateTimeFormat#formatToParts`, then re-checks it at the resulting instant, so times just after a DST change are still correct.

### Normalization rules [#normalization-rules]

`src/lib/normalize.ts` applies these defaults:

* Duration is 60 minutes when neither `end` nor `durationMinutes` is given
* An `end` earlier than `start` rolls over to the next day ("10pm to 1am")
* Text without a time of day becomes an all-day event
* Attendees must be valid emails that appear in the original text
* The model's `hasDateOrTime` flag, or an unparseable `start`, produces `PARSE_ERROR`

### ICS formatting [#ics-formatting]

Lines end in CRLF. Lines longer than 75 octets are folded with a leading space, and multi-byte UTF-8 characters are never split. `\`, `;`, `,` and newlines in TEXT values are escaped.

## Use Cases [#use-cases]

* **"Add to calendar" from chat** - Slack or Discord bots that turn messages into invites
* **Email assistants** - Convert "can we meet Thursday at 3?" into a draft event
* **Quick-add widgets** - A single text box on a website that downloads an `.ics`
* **Voice input** - Pair with speech-to-text to create events by voice

## Limitations [#limitations]

* **Model interpretation**: Ambiguous phrases ("next Tuesday" said on a Monday) follow the model's reading; pass `now` and `timezone` for deterministic tests
* **Single events only**: Recurrence ("every Monday") is not mapped to `RRULE`
* **No invites sent**: `ATTENDEE` lines are included, but no email is sent
* **Attendees by email only**: Names without an email address are ignored
* **Outlook link**: Targets `outlook.live.com` (personal accounts); work accounts use `outlook.office.com`

## Use in your project [#use-in-your-project]

<UseInYourProject
  experiment="natural-language-calendar"
  bindings="[&#x22;Workers AI (AI binding)&#x22;]"
  platform="[&#x22;Workers AI JSON mode&#x22;, &#x22;Intl.DateTimeFormat&#x22;, &#x22;request.cf.timezone&#x22;]"
  files="[
  &#x22;src/lib/event.ts&#x22;,
  &#x22;src/lib/extract.ts&#x22;,
  &#x22;src/lib/normalize.ts&#x22;,
  &#x22;src/lib/timezone.ts&#x22;,
  &#x22;src/lib/ics.ts&#x22;,
  &#x22;src/lib/links.ts&#x22;,
  &#x22;src/lib/input.ts&#x22;,
  &#x22;src/constants/defaults.ts&#x22;,
  &#x22;src/constants/schema.ts&#x22;,
  &#x22;src/types/event.ts&#x22;,
  &#x22;examples/usage.ts&#x22;,
]"
  sources="[{&#x22;path&#x22;:&#x22;src/lib/event.ts&#x22;,&#x22;title&#x22;:&#x22;event.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { ICS_UID_DOMAIN } from \&#x22;../constants/defaults\&#x22;;\nimport type { EventResponse } from \&#x22;../types/event\&#x22;;\nimport { parseModelJson, runEventModel } from \&#x22;./extract\&#x22;;\nimport { normalizeEvent } from \&#x22;./normalize\&#x22;;\nimport { buildIcs } from \&#x22;./ics\&#x22;;\nimport { googleCalendarUrl, outlookUrl } from \&#x22;./links\&#x22;;\n\nexport type CreateEventResult =\n  | { ok: true; data: EventResponse }\n  | { ok: false; code: \&#x22;AI_ERROR\&#x22; | \&#x22;PARSE_ERROR\&#x22;; message: string };\n\n/** Text → model → normalized event → ICS + calendar links. */\nexport async function createEvent(\n  ai: Ai,\n  input: { text: string; timezone: string; now: Date }\n): Promise<CreateEventResult> {\n  let output: unknown;\n  try {\n    output = await runEventModel(ai, input.text, input.timezone, input.now);\n  } catch (err) {\n    const message = err instanceof Error ? err.message : \&#x22;Event extraction failed\&#x22;;\n    return { ok: false, code: \&#x22;AI_ERROR\&#x22;, message };\n  }\n\n  const event = normalizeEvent(parseModelJson(output), input.timezone, input.text);\n  if (!event) {\n    return {\n      ok: false,\n      code: \&#x22;PARSE_ERROR\&#x22;,\n      message:\n        \&#x22;Couldn't find a date in that text. Try rephrasing, e.g. 'Lunch with Sam next Tuesday at 1pm'\&#x22;,\n    };\n  }\n\n  const ics = buildIcs(event, {\n    uid: `${crypto.randomUUID()}@${ICS_UID_DOMAIN}`,\n    dtstamp: new Date(),\n  });\n  return {\n    ok: true,\n    data: {\n      event,\n      ics,\n      googleCalendarUrl: googleCalendarUrl(event),\n      outlookUrl: outlookUrl(event),\n    },\n  };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/extract.ts&#x22;,&#x22;title&#x22;:&#x22;extract.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { AI_MODEL, PROMPT_CALENDAR_DAYS } from \&#x22;../constants/defaults\&#x22;;\nimport { EVENT_JSON_SCHEMA } from \&#x22;../constants/schema\&#x22;;\nimport { addDays, formatLocalDate, zonedParts } from \&#x22;./timezone\&#x22;;\n\nconst WEEKDAYS = [\&#x22;Sunday\&#x22;, \&#x22;Monday\&#x22;, \&#x22;Tuesday\&#x22;, \&#x22;Wednesday\&#x22;, \&#x22;Thursday\&#x22;, \&#x22;Friday\&#x22;, \&#x22;Saturday\&#x22;];\n\n/**\n * LLMs are unreliable at weekday arithmetic, so the prompt lists the next two weeks of dates with\n * their weekdays for resolving phrases like \&#x22;next Tue\&#x22;.\n */\nexport function buildSystemPrompt(timezone: string, now: Date): string {\n  const local = zonedParts(now, timezone);\n  const today = formatLocalDate(local);\n  const time = `${String(local.hour).padStart(2, \&#x22;0\&#x22;)}:${String(local.minute).padStart(2, \&#x22;0\&#x22;)}`;\n  const upcoming = Array.from({ length: PROMPT_CALENDAR_DAYS }, (_, i) => {\n    const day = addDays(local, i);\n    const weekday = WEEKDAYS[new Date(Date.UTC(day.year, day.month - 1, day.day)).getUTCDay()];\n    return `${weekday} ${formatLocalDate(day)}${i === 0 ? \&#x22; (today)\&#x22; : \&#x22;\&#x22;}`;\n  }).join(\&#x22;\\n\&#x22;);\n\n  return [\n    \&#x22;You convert a natural-language event description into a calendar event JSON object.\&#x22;,\n    `The user's time zone is ${timezone}. It is currently ${local.weekday} ${today} ${time} there.`,\n    \&#x22;Upcoming dates:\&#x22;,\n    upcoming,\n    \&#x22;Rules:\&#x22;,\n    \&#x22;- hasDateOrTime is false when the text gives no date, day, or time at all; do not default to today.\&#x22;,\n    \&#x22;- start and end are local wall times in the user's time zone formatted YYYY-MM-DDTHH:mm, with no offset or Z.\&#x22;,\n    \&#x22;- Resolve relative dates (today, tomorrow, next Tuesday, in 3 days) using the dates above. Never pick a date in the past.\&#x22;,\n    \&#x22;- If no time of day is given, set allDay to true and use YYYY-MM-DD for start (and for end on multi-day events, as the last day).\&#x22;,\n    \&#x22;- If an end time is not stated, set end to null and durationMinutes to the stated duration or null.\&#x22;,\n    \&#x22;- attendees: only email addresses that appear in the text. Do not invent addresses.\&#x22;,\n    \&#x22;- title: short and descriptive, without the date or time.\&#x22;,\n  ].join(\&#x22;\\n\&#x22;);\n}\n\nexport async function runEventModel(\n  ai: Ai,\n  text: string,\n  timezone: string,\n  now: Date\n): Promise<unknown> {\n  return ai.run(AI_MODEL, {\n    messages: [\n      { role: \&#x22;system\&#x22;, content: buildSystemPrompt(timezone, now) },\n      { role: \&#x22;user\&#x22;, content: text },\n    ],\n    response_format: { type: \&#x22;json_schema\&#x22;, json_schema: EVENT_JSON_SCHEMA },\n    temperature: 0,\n    max_tokens: 512,\n  });\n}\n\n/** JSON mode may return `response` as a parsed object or a JSON string; null if unusable. */\nexport function parseModelJson(output: unknown): unknown {\n  const response =\n    output && typeof output === \&#x22;object\&#x22; ? (output as { response?: unknown }).response : output;\n  if (response && typeof response === \&#x22;object\&#x22;) return response;\n  if (typeof response !== \&#x22;string\&#x22;) return null;\n  try {\n    return JSON.parse(\n      response\n        .trim()\n        .replace(/^```(?:json)?\\s*/i, \&#x22;\&#x22;)\n        .replace(/```$/, \&#x22;\&#x22;)\n        .trim()\n    );\n  } catch {\n    return null;\n  }\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/normalize.ts&#x22;,&#x22;title&#x22;:&#x22;normalize.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import {\n  DEFAULT_DURATION_MINUTES,\n  MAX_ATTENDEES,\n  MAX_DURATION_MINUTES,\n  MAX_FIELD_LENGTH,\n  MAX_TITLE_LENGTH,\n} from \&#x22;../constants/defaults\&#x22;;\nimport type { CalendarEvent, LocalDateTime } from \&#x22;../types/event\&#x22;;\nimport {\n  addDays,\n  compareDates,\n  formatLocalDate,\n  formatLocalDateTime,\n  parseLocalDateTime,\n  zonedParts,\n  zonedTimeToUtc,\n} from \&#x22;./timezone\&#x22;;\n\nconst EMAIL_PATTERN = /^[^\\s@<>(),;:\&#x22;]+@[^\\s@<>(),;:\&#x22;]+\\.[a-z]{2,}$/i;\nconst MINUTE_MS = 60_000;\n\nfunction toText(value: unknown, max: number): string | null {\n  if (typeof value !== \&#x22;string\&#x22;) return null;\n  const trimmed = value.trim();\n  return trimmed ? trimmed.slice(0, max) : null;\n}\n\n/** Keeps only valid emails that literally appear in the user's text, so invented ones are dropped. */\nexport function normalizeAttendees(value: unknown, sourceText: string): string[] {\n  if (!Array.isArray(value)) return [];\n  const haystack = sourceText.toLowerCase();\n  const emails = value\n    .filter((item): item is string => typeof item === \&#x22;string\&#x22;)\n    .map((item) =>\n      item\n        .trim()\n        .replace(/^mailto:/i, \&#x22;\&#x22;)\n        .toLowerCase()\n    )\n    .filter((email) => EMAIL_PATTERN.test(email) && haystack.includes(email));\n  return [...new Set(emails)].slice(0, MAX_ATTENDEES);\n}\n\nfunction toDuration(value: unknown): number | null {\n  const minutes = typeof value === \&#x22;string\&#x22; ? Number(value) : value;\n  if (typeof minutes !== \&#x22;number\&#x22; || !Number.isFinite(minutes)) return null;\n  const rounded = Math.round(minutes);\n  return rounded >= 1 && rounded <= MAX_DURATION_MINUTES ? rounded : null;\n}\n\nfunction allDayEvent(\n  start: LocalDateTime,\n  endInput: unknown,\n  base: Omit<CalendarEvent, \&#x22;allDay\&#x22; | \&#x22;start\&#x22; | \&#x22;end\&#x22; | \&#x22;startUtc\&#x22; | \&#x22;endUtc\&#x22; | \&#x22;durationMinutes\&#x22;>\n): CalendarEvent {\n  const lastDay = parseLocalDateTime(endInput)?.local;\n  const inclusiveEnd = lastDay && compareDates(lastDay, start) >= 0 ? lastDay : start;\n  const days = Math.min(\n    Math.round(compareDates(inclusiveEnd, start) / (24 * 60 * MINUTE_MS)) + 1,\n    MAX_DURATION_MINUTES / (24 * 60)\n  );\n  return {\n    ...base,\n    allDay: true,\n    start: formatLocalDate(start),\n    end: formatLocalDate(addDays(start, days)),\n    startUtc: null,\n    endUtc: null,\n    durationMinutes: days * 24 * 60,\n  };\n}\n\n/**\n * Turns untrusted model output into a valid event in `timezone`. Returns null when there is no\n * usable start date.\n */\nexport function normalizeEvent(\n  raw: unknown,\n  timezone: string,\n  sourceText: string\n): CalendarEvent | null {\n  if (!raw || typeof raw !== \&#x22;object\&#x22;) return null;\n  const input = raw as Record<string, unknown>;\n  if (input.hasDateOrTime === false) return null;\n\n  const parsedStart = parseLocalDateTime(input.start);\n  if (!parsedStart) return null;\n  const start = { ...parsedStart.local, second: 0 };\n\n  const base = {\n    title: toText(input.title, MAX_TITLE_LENGTH) ?? \&#x22;New event\&#x22;,\n    timezone,\n    location: toText(input.location, MAX_FIELD_LENGTH),\n    description: toText(input.description, MAX_FIELD_LENGTH),\n    attendees: normalizeAttendees(input.attendees, sourceText),\n  };\n\n  if (input.allDay === true || !parsedStart.hasTime) {\n    return allDayEvent(start, input.end, base);\n  }\n\n  const startUtc = zonedTimeToUtc(start, timezone);\n  let endUtc: Date | null = null;\n\n  const parsedEnd = parseLocalDateTime(input.end);\n  if (parsedEnd?.hasTime) {\n    let candidate = zonedTimeToUtc({ ...parsedEnd.local, second: 0 }, timezone);\n    // \&#x22;10pm to 1am\&#x22; often comes back with the start date on both ends.\n    if (candidate <= startUtc) candidate = new Date(candidate.getTime() + 24 * 60 * MINUTE_MS);\n    const minutes = (candidate.getTime() - startUtc.getTime()) / MINUTE_MS;\n    if (minutes > 0 && minutes <= MAX_DURATION_MINUTES) endUtc = candidate;\n  }\n\n  if (!endUtc) {\n    const minutes = toDuration(input.durationMinutes) ?? DEFAULT_DURATION_MINUTES;\n    endUtc = new Date(startUtc.getTime() + minutes * MINUTE_MS);\n  }\n\n  return {\n    ...base,\n    allDay: false,\n    start: formatLocalDateTime(start),\n    end: formatLocalDateTime(zonedParts(endUtc, timezone)),\n    startUtc: startUtc.toISOString(),\n    endUtc: endUtc.toISOString(),\n    durationMinutes: Math.round((endUtc.getTime() - startUtc.getTime()) / MINUTE_MS),\n  };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/timezone.ts&#x22;,&#x22;title&#x22;:&#x22;timezone.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import type { LocalDateTime } from \&#x22;../types/event\&#x22;;\n\nconst formatters = new Map<string, Intl.DateTimeFormat>();\n\nfunction formatter(timeZone: string): Intl.DateTimeFormat {\n  let fmt = formatters.get(timeZone);\n  if (!fmt) {\n    fmt = new Intl.DateTimeFormat(\&#x22;en-US\&#x22;, {\n      timeZone,\n      hourCycle: \&#x22;h23\&#x22;,\n      year: \&#x22;numeric\&#x22;,\n      month: \&#x22;2-digit\&#x22;,\n      day: \&#x22;2-digit\&#x22;,\n      hour: \&#x22;2-digit\&#x22;,\n      minute: \&#x22;2-digit\&#x22;,\n      second: \&#x22;2-digit\&#x22;,\n      weekday: \&#x22;long\&#x22;,\n    });\n    formatters.set(timeZone, fmt);\n  }\n  return fmt;\n}\n\n/**\n * Returns a valid IANA name with corrected casing (\&#x22;america/new_york\&#x22; → \&#x22;America/New_York\&#x22;), or\n * null. Intl maps some current names to legacy aliases (Asia/Kolkata → Asia/Calcutta), so the\n * resolved name is only used when it differs from the input by case.\n */\nexport function canonicalTimeZone(input: string): string | null {\n  const trimmed = input.trim();\n  if (!trimmed || trimmed.length > 64) return null;\n  try {\n    const resolved = new Intl.DateTimeFormat(\&#x22;en-US\&#x22;, { timeZone: trimmed }).resolvedOptions()\n      .timeZone;\n    return resolved.toLowerCase() === trimmed.toLowerCase() ? resolved : trimmed;\n  } catch {\n    return null;\n  }\n}\n\n/** Wall-clock parts (and weekday name) of an instant in a time zone. */\nexport function zonedParts(date: Date, timeZone: string): LocalDateTime & { weekday: string } {\n  const parts: Record<string, string> = {};\n  for (const { type, value } of formatter(timeZone).formatToParts(date)) parts[type] = value;\n  return {\n    year: Number(parts.year),\n    month: Number(parts.month),\n    day: Number(parts.day),\n    hour: Number(parts.hour),\n    minute: Number(parts.minute),\n    second: Number(parts.second),\n    weekday: parts.weekday,\n  };\n}\n\nfunction localAsUtcMs(local: LocalDateTime): number {\n  return Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second);\n}\n\nfunction offsetMs(utcMs: number, timeZone: string): number {\n  const wholeSecond = Math.floor(utcMs / 1000) * 1000;\n  return localAsUtcMs(zonedParts(new Date(wholeSecond), timeZone)) - wholeSecond;\n}\n\n/**\n * Converts wall-clock time in `timeZone` to a UTC instant. The second pass corrects the offset\n * when the first guess lands on the other side of a DST transition.\n */\nexport function zonedTimeToUtc(local: LocalDateTime, timeZone: string): Date {\n  const naive = localAsUtcMs(local);\n  const firstOffset = offsetMs(naive, timeZone);\n  let utc = naive - firstOffset;\n  const secondOffset = offsetMs(utc, timeZone);\n  if (secondOffset !== firstOffset) utc = naive - secondOffset;\n  return new Date(utc);\n}\n\nfunction pad(value: number, length = 2): string {\n  return String(value).padStart(length, \&#x22;0\&#x22;);\n}\n\nexport function formatLocalDate(local: Pick<LocalDateTime, \&#x22;year\&#x22; | \&#x22;month\&#x22; | \&#x22;day\&#x22;>): string {\n  return `${pad(local.year, 4)}-${pad(local.month)}-${pad(local.day)}`;\n}\n\nexport function formatLocalDateTime(local: LocalDateTime): string {\n  return `${formatLocalDate(local)}T${pad(local.hour)}:${pad(local.minute)}:${pad(local.second)}`;\n}\n\n/** Adds days to a calendar date without involving time zones. */\nexport function addDays(local: LocalDateTime, days: number): LocalDateTime {\n  const date = new Date(Date.UTC(local.year, local.month - 1, local.day + days));\n  return {\n    ...local,\n    year: date.getUTCFullYear(),\n    month: date.getUTCMonth() + 1,\n    day: date.getUTCDate(),\n  };\n}\n\n/**\n * Parses \&#x22;YYYY-MM-DD\&#x22;, \&#x22;YYYY-MM-DDTHH:mm\&#x22;, or \&#x22;YYYY-MM-DDTHH:mm:ss\&#x22; as wall time. Any trailing\n * offset or \&#x22;Z\&#x22; is ignored because the model is asked for times in the user's zone.\n */\nexport function parseLocalDateTime(\n  input: unknown\n): { local: LocalDateTime; hasTime: boolean } | null {\n  if (typeof input !== \&#x22;string\&#x22;) return null;\n  const match =\n    /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2}))?(?:\\.\\d+)?)?(?:Z|[+-]\\d{2}:?\\d{2})?$/i.exec(\n      input.trim()\n    );\n  if (!match) return null;\n\n  const [, y, mo, d, h, mi, s] = match;\n  const local: LocalDateTime = {\n    year: Number(y),\n    month: Number(mo),\n    day: Number(d),\n    hour: h ? Number(h) : 0,\n    minute: mi ? Number(mi) : 0,\n    second: s ? Number(s) : 0,\n  };\n  const check = new Date(localAsUtcMs(local));\n  const valid =\n    local.hour < 24 &&\n    local.minute < 60 &&\n    local.second < 60 &&\n    check.getUTCFullYear() === local.year &&\n    check.getUTCMonth() === local.month - 1 &&\n    check.getUTCDate() === local.day;\n  return valid ? { local, hasTime: Boolean(h) } : null;\n}\n\nexport function compareDates(a: LocalDateTime, b: LocalDateTime): number {\n  return localAsUtcMs(a) - localAsUtcMs(b);\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/ics.ts&#x22;,&#x22;title&#x22;:&#x22;ics.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { ICS_PRODID } from \&#x22;../constants/defaults\&#x22;;\nimport type { CalendarEvent } from \&#x22;../types/event\&#x22;;\n\nconst CRLF = \&#x22;\\r\\n\&#x22;;\nconst MAX_LINE_OCTETS = 75;\nconst encoder = new TextEncoder();\n\n/** Escapes a TEXT value per RFC 5545 §3.3.11. */\nexport function escapeText(value: string): string {\n  return value\n    .replace(/\\\\/g, \&#x22;\\\\\\\\\&#x22;)\n    .replace(/;/g, \&#x22;\\\\;\&#x22;)\n    .replace(/,/g, \&#x22;\\\\,\&#x22;)\n    .replace(/\\r\\n|\\r|\\n/g, \&#x22;\\\\n\&#x22;);\n}\n\n/**\n * Folds a content line at 75 octets (RFC 5545 §3.1). Continuation lines start with a space, which\n * counts toward their 75 octets; multi-byte UTF-8 characters are never split.\n */\nexport function foldLine(line: string): string {\n  const lines: string[] = [];\n  let current = \&#x22;\&#x22;;\n  let octets = 0;\n  for (const char of line) {\n    const size = encoder.encode(char).length;\n    const limit = lines.length === 0 ? MAX_LINE_OCTETS : MAX_LINE_OCTETS - 1;\n    if (octets + size > limit) {\n      lines.push(current);\n      current = \&#x22;\&#x22;;\n      octets = 0;\n    }\n    current += char;\n    octets += size;\n  }\n  lines.push(current);\n  return lines.join(`${CRLF} `);\n}\n\n/** 2026-10-06T13:00:00.000Z → 20261006T130000Z */\nexport function formatUtcStamp(date: Date): string {\n  return date\n    .toISOString()\n    .replace(/[-:]/g, \&#x22;\&#x22;)\n    .replace(/\\.\\d{3}/, \&#x22;\&#x22;);\n}\n\n/** 2026-10-06 → 20261006 */\nexport function formatDateValue(isoDate: string): string {\n  return isoDate.slice(0, 10).replace(/-/g, \&#x22;\&#x22;);\n}\n\n/**\n * Builds a single-event VCALENDAR. Timed events use UTC (`DTSTART:...Z`) instead of a TZID, so no\n * VTIMEZONE block is needed and every client shows the correct instant.\n */\nexport function buildIcs(event: CalendarEvent, options: { uid: string; dtstamp: Date }): string {\n  const lines = [\n    \&#x22;BEGIN:VCALENDAR\&#x22;,\n    \&#x22;VERSION:2.0\&#x22;,\n    `PRODID:${ICS_PRODID}`,\n    \&#x22;CALSCALE:GREGORIAN\&#x22;,\n    \&#x22;BEGIN:VEVENT\&#x22;,\n    `UID:${options.uid}`,\n    `DTSTAMP:${formatUtcStamp(options.dtstamp)}`,\n  ];\n\n  if (event.allDay || !event.startUtc || !event.endUtc) {\n    lines.push(`DTSTART;VALUE=DATE:${formatDateValue(event.start)}`);\n    lines.push(`DTEND;VALUE=DATE:${formatDateValue(event.end)}`);\n  } else {\n    lines.push(`DTSTART:${formatUtcStamp(new Date(event.startUtc))}`);\n    lines.push(`DTEND:${formatUtcStamp(new Date(event.endUtc))}`);\n  }\n\n  lines.push(`SUMMARY:${escapeText(event.title)}`);\n  if (event.location) lines.push(`LOCATION:${escapeText(event.location)}`);\n  if (event.description) lines.push(`DESCRIPTION:${escapeText(event.description)}`);\n  for (const email of event.attendees) {\n    lines.push(`ATTENDEE;RSVP=TRUE:mailto:${email}`);\n  }\n  lines.push(\&#x22;END:VEVENT\&#x22;, \&#x22;END:VCALENDAR\&#x22;);\n\n  return lines.map(foldLine).join(CRLF) + CRLF;\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/links.ts&#x22;,&#x22;title&#x22;:&#x22;links.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import type { CalendarEvent } from \&#x22;../types/event\&#x22;;\nimport { formatDateValue, formatUtcStamp } from \&#x22;./ics\&#x22;;\n\n/** Encodes spaces as %20 (not \&#x22;+\&#x22;), which Outlook would otherwise show literally. */\nfunction query(params: Record<string, string | null | undefined>): string {\n  return Object.entries(params)\n    .filter((entry): entry is [string, string] => Boolean(entry[1]))\n    .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)\n    .join(\&#x22;&\&#x22;);\n}\n\nfunction isTimed(\n  event: CalendarEvent\n): event is CalendarEvent & { startUtc: string; endUtc: string } {\n  return !event.allDay && Boolean(event.startUtc && event.endUtc);\n}\n\nexport function googleCalendarUrl(event: CalendarEvent): string {\n  const dates = isTimed(event)\n    ? `${formatUtcStamp(new Date(event.startUtc))}/${formatUtcStamp(new Date(event.endUtc))}`\n    : `${formatDateValue(event.start)}/${formatDateValue(event.end)}`;\n  return `https://calendar.google.com/calendar/render?${query({\n    action: \&#x22;TEMPLATE\&#x22;,\n    text: event.title,\n    dates,\n    details: event.description,\n    location: event.location,\n    add: event.attendees.join(\&#x22;,\&#x22;) || null,\n    ctz: event.timezone,\n  })}`;\n}\n\nexport function outlookUrl(event: CalendarEvent): string {\n  const timed = isTimed(event);\n  return `https://outlook.live.com/calendar/0/deeplink/compose?${query({\n    path: \&#x22;/calendar/action/compose\&#x22;,\n    rru: \&#x22;addevent\&#x22;,\n    subject: event.title,\n    startdt: timed ? event.startUtc.replace(/\\.\\d{3}Z$/, \&#x22;Z\&#x22;) : event.start,\n    enddt: timed ? event.endUtc.replace(/\\.\\d{3}Z$/, \&#x22;Z\&#x22;) : event.end,\n    allday: timed ? \&#x22;false\&#x22; : \&#x22;true\&#x22;,\n    body: event.description,\n    location: event.location,\n    to: event.attendees.join(\&#x22;,\&#x22;) || null,\n  })}`;\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/input.ts&#x22;,&#x22;title&#x22;:&#x22;input.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { DEFAULT_TIMEZONE, MAX_TEXT_LENGTH } from \&#x22;../constants/defaults\&#x22;;\nimport { canonicalTimeZone } from \&#x22;./timezone\&#x22;;\n\nexport function validateText(input: unknown): string | null {\n  if (typeof input !== \&#x22;string\&#x22;) return null;\n  const trimmed = input.trim();\n  return trimmed && trimmed.length <= MAX_TEXT_LENGTH ? trimmed : null;\n}\n\n/**\n * An explicit timezone must be valid; otherwise fall back to the visitor's `request.cf.timezone`,\n * then UTC. Returns null only for an invalid explicit value.\n */\nexport function resolveTimeZone(explicit: unknown, cfTimezone: unknown): string | null {\n  if (explicit !== undefined && explicit !== null && explicit !== \&#x22;\&#x22;) {\n    return typeof explicit === \&#x22;string\&#x22; ? canonicalTimeZone(explicit) : null;\n  }\n  if (typeof cfTimezone === \&#x22;string\&#x22;) {\n    const fromCf = canonicalTimeZone(cfTimezone);\n    if (fromCf) return fromCf;\n  }\n  return DEFAULT_TIMEZONE;\n}\n\n/** Optional reference time for deterministic parsing; defaults to the current time. */\nexport function resolveNow(input: unknown): Date | null {\n  if (input === undefined || input === null || input === \&#x22;\&#x22;) return new Date();\n  if (typeof input !== \&#x22;string\&#x22;) return null;\n  const date = new Date(input);\n  return Number.isFinite(date.getTime()) ? date : null;\n}&#x22;},{&#x22;path&#x22;:&#x22;src/constants/defaults.ts&#x22;,&#x22;title&#x22;:&#x22;defaults.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export const AI_MODEL = \&#x22;@cf/meta/llama-3.3-70b-instruct-fp8-fast\&#x22;;\nexport const MAX_TEXT_LENGTH = 500;\nexport const DEFAULT_TIMEZONE = \&#x22;UTC\&#x22;;\nexport const DEFAULT_DURATION_MINUTES = 60;\nexport const MAX_DURATION_MINUTES = 60 * 24 * 14;\nexport const MAX_ATTENDEES = 20;\nexport const MAX_TITLE_LENGTH = 200;\nexport const MAX_FIELD_LENGTH = 1_000;\nexport const PROMPT_CALENDAR_DAYS = 14;\n\nexport const ICS_PRODID = \&#x22;-//cloudflare-experiments//natural-language-calendar//EN\&#x22;;\nexport const ICS_UID_DOMAIN = \&#x22;natural-language-calendar.workers.dev\&#x22;;\nexport const ICS_FILENAME = \&#x22;event.ics\&#x22;;&#x22;},{&#x22;path&#x22;:&#x22;src/constants/schema.ts&#x22;,&#x22;title&#x22;:&#x22;schema.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export const EVENT_JSON_SCHEMA = {\n  type: \&#x22;object\&#x22;,\n  properties: {\n    hasDateOrTime: {\n      type: \&#x22;boolean\&#x22;,\n      description: \&#x22;true only if the text states a date, weekday, relative day, or time\&#x22;,\n    },\n    title: { type: \&#x22;string\&#x22;, description: \&#x22;Short event title\&#x22; },\n    start: {\n      type: \&#x22;string\&#x22;,\n      description: \&#x22;Local start as YYYY-MM-DDTHH:mm (timed) or YYYY-MM-DD (all-day), no offset\&#x22;,\n    },\n    end: {\n      type: [\&#x22;string\&#x22;, \&#x22;null\&#x22;],\n      description: \&#x22;Local end in the same format as start, or null if not stated\&#x22;,\n    },\n    durationMinutes: { type: [\&#x22;number\&#x22;, \&#x22;null\&#x22;] },\n    location: { type: [\&#x22;string\&#x22;, \&#x22;null\&#x22;] },\n    description: { type: [\&#x22;string\&#x22;, \&#x22;null\&#x22;] },\n    allDay: { type: \&#x22;boolean\&#x22; },\n    attendees: {\n      type: \&#x22;array\&#x22;,\n      items: { type: \&#x22;string\&#x22; },\n      description: \&#x22;Email addresses mentioned in the text\&#x22;,\n    },\n  },\n  required: [\n    \&#x22;hasDateOrTime\&#x22;,\n    \&#x22;title\&#x22;,\n    \&#x22;start\&#x22;,\n    \&#x22;end\&#x22;,\n    \&#x22;durationMinutes\&#x22;,\n    \&#x22;location\&#x22;,\n    \&#x22;description\&#x22;,\n    \&#x22;allDay\&#x22;,\n    \&#x22;attendees\&#x22;,\n  ],\n} as const;&#x22;},{&#x22;path&#x22;:&#x22;src/types/event.ts&#x22;,&#x22;title&#x22;:&#x22;event.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;/** Wall-clock date/time in some time zone (month is 1-based). */\nexport interface LocalDateTime {\n  year: number;\n  month: number;\n  day: number;\n  hour: number;\n  minute: number;\n  second: number;\n}\n\nexport interface EventRequestBody {\n  text?: unknown;\n  timezone?: unknown;\n  now?: unknown;\n}\n\nexport interface CalendarEvent {\n  title: string;\n  allDay: boolean;\n  /** IANA time zone the local times are in */\n  timezone: string;\n  /** Local wall time: YYYY-MM-DDTHH:mm:ss, or YYYY-MM-DD for all-day events */\n  start: string;\n  /** Local end; for all-day events this is the exclusive end date */\n  end: string;\n  /** UTC instants (ISO 8601); null for all-day events */\n  startUtc: string | null;\n  endUtc: string | null;\n  durationMinutes: number;\n  location: string | null;\n  description: string | null;\n  attendees: string[];\n}\n\nexport interface EventResponse {\n  event: CalendarEvent;\n  ics: string;\n  googleCalendarUrl: string;\n  outlookUrl: string;\n}&#x22;},{&#x22;path&#x22;:&#x22;examples/usage.ts&#x22;,&#x22;title&#x22;:&#x22;usage.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;/**\n * Minimal paste example for docs (\&#x22;Use in your project\&#x22;).\n * Not part of the Worker runtime — copy into your own project.\n *\n * Typical API: GET /event.ics?text=Lunch next Tue 1pm&timezone=America/New_York\n */\nimport { createEvent } from \&#x22;../src/lib/event\&#x22;;\nimport { resolveTimeZone, validateText } from \&#x22;../src/lib/input\&#x22;;\n\nexport default {\n  async fetch(request: Request, env: { AI: Ai }): Promise<Response> {\n    const params = new URL(request.url).searchParams;\n    const text = validateText(params.get(\&#x22;text\&#x22;));\n    const timezone = resolveTimeZone(params.get(\&#x22;timezone\&#x22;) ?? undefined, request.cf?.timezone);\n    if (!text || !timezone) {\n      return Response.json(\n        { error: \&#x22;Invalid text or timezone\&#x22;, code: \&#x22;INVALID_TEXT\&#x22; },\n        { status: 400 }\n      );\n    }\n\n    const result = await createEvent(env.AI, { text, timezone, now: new Date() });\n    if (!result.ok)\n      return Response.json({ error: result.message, code: result.code }, { status: 400 });\n\n    return new Response(result.data.ics, {\n      headers: {\n        \&#x22;Content-Type\&#x22;: \&#x22;text/calendar; charset=utf-8\&#x22;,\n        \&#x22;Content-Disposition\&#x22;: \&#x22;attachment; filename=event.ics\&#x22;,\n      },\n    });\n  },\n};&#x22;}]"
/>

## Deployment [#deployment]

<Steps>
  <Step>
    ### Click the deploy button [#click-the-deploy-button]

    [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/natural-language-calendar)
  </Step>

  <Step>
    ### Deploy [#deploy]

    The `AI` binding is configured in `wrangler.json`. Requires a Cloudflare account with Workers AI enabled.
  </Step>

  <Step>
    ### Test your deployment [#test-your-deployment]

    ```bash
    curl -OJ "https://your-worker.workers.dev/event.ics?text=Coffee%20tomorrow%2010am"
    ```
  </Step>
</Steps>

## Local Development [#local-development]

```bash
cd apps/experiments/natural-language-calendar
npm install
npm run dev
```

```bash
curl -X POST "http://localhost:8787/event" \
  -H "Content-Type: application/json" \
  -d '{"text":"Dentist tomorrow 9:30am for 45 minutes","timezone":"Asia/Kolkata"}'
```

The `AI` binding always runs remotely, so local development needs a logged-in Wrangler session.

## Configuration [#configuration]

`wrangler.json` declares a single binding:

```json
{
  "ai": { "binding": "AI" }
}
```

Defaults such as `AI_MODEL`, `MAX_TEXT_LENGTH`, and `DEFAULT_DURATION_MINUTES` live in `src/constants/defaults.ts`.

## Cloudflare Features Used [#cloudflare-features-used]

* **[Workers](https://developers.cloudflare.com/workers/)** - Edge compute runtime; `request.cf.timezone` for the default zone
* **[Workers AI JSON Mode](https://developers.cloudflare.com/workers-ai/features/json-mode/)** - Schema-constrained output from `@cf/meta/llama-3.3-70b-instruct-fp8-fast`

## Next Steps [#next-steps]

<Cards>
  <Card title="Receipt Parser" href="/experiments/receipt-parser">
    Another JSON-mode extraction experiment
  </Card>

  <Card title="RFC 5545" href="https://www.rfc-editor.org/rfc/rfc5545">
    The iCalendar specification
  </Card>

  <Card title="GitHub Repository" href="https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/natural-language-calendar">
    View the complete source code
  </Card>
</Cards>
