Natural Language Calendar
Turn plain English into a calendar invite with Workers AI JSON mode - parse events and return .ics-ready structured data
This is an experimental Worker. Use it as a starting point for your own projects.
Type "Lunch with sam@example.com next Tue 1pm at Blue Bottle" and get back a real calendar invite. Workers AI 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
- 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.icsdownloads directly into Calendar apps- Attendee emails are kept only if they appear in the input text
API Reference
POST /event
Parse text into an event.
Prop
Type
Example Request
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
{
"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 / 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
Same inputs as query parameters: text, timezone, now. Returns text/calendar; charset=utf-8 with Content-Disposition: attachment; filename=event.ics.
curl -OJ "https://your-worker.workers.dev/event.ics?text=Team%20offsite%20Oct%2012-13%20in%20Lisbon&timezone=Europe/Lisbon"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:VCALENDARError Responses
{
"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(POSTbody is not JSON),PARSE_ERROR(model output has no usable date)502-AI_ERROR(model call failed)
Implementation Details
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
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
src/lib/normalize.ts applies these defaults:
- Duration is 60 minutes when neither
endnordurationMinutesis given - An
endearlier thanstartrolls 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
hasDateOrTimeflag, or an unparseablestart, producesPARSE_ERROR
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
- "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
- Model interpretation: Ambiguous phrases ("next Tuesday" said on a Monday) follow the model's reading; pass
nowandtimezonefor deterministic tests - Single events only: Recurrence ("every Monday") is not mapped to
RRULE - No invites sent:
ATTENDEElines 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 useoutlook.office.com
Use in your project
Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/natural-language-calendar.
import { ICS_UID_DOMAIN } from "../constants/defaults";import type { EventResponse } from "../types/event";import { parseModelJson, runEventModel } from "./extract";import { normalizeEvent } from "./normalize";import { buildIcs } from "./ics";import { googleCalendarUrl, outlookUrl } from "./links";export type CreateEventResult = | { ok: true; data: EventResponse } | { ok: false; code: "AI_ERROR" | "PARSE_ERROR"; message: string };/** Text → model → normalized event → ICS + calendar links. */export async function createEvent( ai: Ai, input: { text: string; timezone: string; now: Date }): Promise<CreateEventResult> { let output: unknown; try { output = await runEventModel(ai, input.text, input.timezone, input.now); } catch (err) { const message = err instanceof Error ? err.message : "Event extraction failed"; return { ok: false, code: "AI_ERROR", message }; } const event = normalizeEvent(parseModelJson(output), input.timezone, input.text); if (!event) { return { ok: false, code: "PARSE_ERROR", message: "Couldn't find a date in that text. Try rephrasing, e.g. 'Lunch with Sam next Tuesday at 1pm'", }; } const ics = buildIcs(event, { uid: `${crypto.randomUUID()}@${ICS_UID_DOMAIN}`, dtstamp: new Date(), }); return { ok: true, data: { event, ics, googleCalendarUrl: googleCalendarUrl(event), outlookUrl: outlookUrl(event), }, };}Deployment
Deploy
The AI binding is configured in wrangler.json. Requires a Cloudflare account with Workers AI enabled.
Test your deployment
curl -OJ "https://your-worker.workers.dev/event.ics?text=Coffee%20tomorrow%2010am"Local Development
cd apps/experiments/natural-language-calendar
npm install
npm run devcurl -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
wrangler.json declares a single binding:
{
"ai": { "binding": "AI" }
}Defaults such as AI_MODEL, MAX_TEXT_LENGTH, and DEFAULT_DURATION_MINUTES live in src/constants/defaults.ts.
Cloudflare Features Used
- Workers - Edge compute runtime;
request.cf.timezonefor the default zone - Workers AI JSON Mode - Schema-constrained output from
@cf/meta/llama-3.3-70b-instruct-fp8-fast