TL;DR
A customer messages "can I get in Thursday afternoon?" and a small AI answers in plain English, checks your real Google Calendar for open slots, books the one they pick, and sends the confirmation. No app, no monthly fee. It runs on Cloudflare's free tier and writes straight into the calendar you already use. You own all of it.
The problem this solves
A booking tool is the one piece of software almost every service business rents forever. You pay $20 to $50 a month, your availability lives inside someone else's dashboard, and the customer still has to leave the conversation, open a form, pick from a stiff grid, and hope they got the right one.
The thing is, "check my calendar and book a time" is a small, well-understood job. An LLM that can call three functions does it conversationally, and the three functions are just three Google Calendar API calls. This file builds that agent as code you own: one chat endpoint, your calendar, your rules, $0 a month to host.
The full picture
Every box below is one small piece. Build them left to right.
THE BOOKING LOOP
═══════════════════════════════════════════════════════════════════════
┌──────────────────┐
┌──────────────┐ "Thursday ┌────────────────┐ │ list_free_slots │
│ CUSTOMER │ at 2?" │ /chat ENDPOINT │ │ freeBusy query │
│ messages │ ──────────▶ │ (Hono on a │ └────────┬─────────┘
│ you │ │ CF Worker) │ │ open windows
└──────┬───────┘ └───────┬────────┘ ▼
│ │ message ┌──────────────────┐
│ confirmation ▼ │ GOOGLE CALENDAR │
│ text ┌────────────────┐ │ (your real one) │
▼ │ LLM BRAIN │ └────────┬─────────┘
┌──────────────┐ │ picks the tool │ │
│ REPLY │ ◀────────── │ to call, then │ ◀───────────┘
│ "Booked you │ answer │ writes a reply │ event id +
│ Thu 2:00pm" │ └───────┬────────┘ confirm link
└──────────────┘ │
┌───────────┴───────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ book_appointment │ │ cancel_appointment│
│ create event │ │ delete event │
└──────────────────┘ └──────────────────┘
═══════════════════════════════════════════════════════════════════════
The LLM never touches your calendar directly. It only decides which of the three tools to call; each tool is one plain Google Calendar REST request. That separation is what keeps it safe and cheap.
What you need
| Piece | What it does | Pick |
|---|---|---|
| Chat endpoint | Receives the message, runs the loop | One Cloudflare Worker with Hono, one /chat route |
| LLM with tool calling | Decides which tool to call, writes the reply | Gemini 1.5 Flash (cheapest) or GPT-4o-mini |
| Calendar API | Reads free slots, creates and deletes events | Google Calendar REST API, called with raw fetch |
| Session store | Remembers the conversation between messages | Cloudflare KV, or in-memory to start |
| OAuth refresh token | Lets the Worker act on your calendar | One-time Google sign-in, stored as a Worker secret |
You do not need the googleapis package. Every calendar call is a single fetch(),
which is exactly what runs cleanly on a Worker.
Phase 1, The chat endpoint that talks back (build this first)
message ──▶ /chat ──▶ LLM (no tools yet) ──▶ plain reply ──▶ back to sender
- Make one Cloudflare Worker with Hono and a single
POST /chatroute. It takes{ "message": "...", "senderId": "..." }and returns{ "reply": "...", "status": "active" }. - Wire one LLM call. Send the user message, get text back, return it. No calendar yet. Prove the round trip works end to end before adding any tools.
- Pick your engine with one env var.
LLM_PROVIDER=geminioropenai. Toggling later should never touch the rest of the code. - Add the session store. Key by
senderId, keep the last few turns so "yeah, 2pm works" still makes sense after "what do you have Thursday?".
You know Phase 1 works when: you curl the endpoint, say hello, and get a sane
sentence back in under two seconds.
Phase 2, The three calendar tools (this is the product)
Now give the LLM exactly three tools. Each one is a single REST call. Define them in the model's tool/function schema, then run the standard tool-calling loop: model asks to call a tool, you run the real API call, you feed the result back, model replies.
USER MESSAGE
│
▼
┌──────────────────────────────────────────────────────────────┐
│ LLM picks ONE tool │
├──────────────────────┬────────────────────┬──────────────────┤
│ list_free_slots │ book_appointment │ cancel_appointment│
│ POST .../freeBusy │ POST .../events │ DELETE .../events/│
│ returns open windows │ returns event id │ {eventId} │
└──────────┬───────────┴─────────┬──────────┴────────┬─────────┘
▼ ▼ ▼
"I have 1, 2, "Booked. Thursday "Done, that slot is
and 4pm open" 2:00pm is set." open again."
The three real endpoints, confirmed against the Google Calendar REST API:
| Tool | Calendar API call | Returns |
|---|---|---|
list_free_slots |
POST https://www.googleapis.com/calendar/v3/freeBusy |
busy blocks, you invert to free windows inside business hours |
book_appointment |
POST https://www.googleapis.com/calendar/v3/calendars/primary/events |
the new event id and a confirmation link |
cancel_appointment |
DELETE https://www.googleapis.com/calendar/v3/calendars/primary/events/{eventId} |
empty on success |
The booking guardrails (non-negotiable):
- Never double-book. Always run
list_free_slotsagain right beforebook_appointment, the customer may have spent two minutes choosing. - Confirm before you write. The model proposes a specific time and waits for a
clear yes before it ever calls
book_appointment. - Stay inside business hours. Free-slot logic clips to the hours you set, so the agent never offers 6am.
- One calendar is the source of truth. The agent reads and writes your real Google Calendar, so a booking you make by hand blocks the agent automatically.
Phase 3, Connect it to where customers actually message
The /chat endpoint is channel-agnostic on purpose. The same JSON in and out works
for any front door.
web chat widget ─┐
SMS (Twilio) ─┤
WhatsApp ─┼──▶ POST /chat ──▶ same booking loop
Telegram ─┘
Start with the simplest one you have, a chat widget on your site or an SMS number,
and point its inbound webhook at /chat. Nothing about the agent changes.
How the calendar access works (the one-time OAuth part)
You sign in to Google once and store a refresh token. After that the Worker handles itself.
YOU (once) THE WORKER (every booking)
────────── ──────────────────────────
sign in with Google ──▶ has the refresh token (a secret)
approve calendar access ──▶ trades it for a 1-hour access token
copy the refresh token ──▶ caches that token until it expires
save it as a secret ──▶ makes the calendar call, books it
The refresh token is a secret. It goes in wrangler secret put, never in code, never
in git, never in this file.
Data model, what to remember
You barely need a database. One small record per conversation is plenty.
booking_sessions
┌────────────────┬──────────────────────────────────────────┐
│ sender_id │ who is chatting (phone, chat id) │
│ history │ the last few turns, for context │
│ last_offered │ slots the agent quoted (prevents drift) │
│ booked_event │ Google Calendar event id, once confirmed │
│ updated_at │ timestamp, for cleanup │
└────────────────┴──────────────────────────────────────────┘
Cloudflare KV holds this fine. The real, permanent record of the appointment lives where it should: in your Google Calendar.
Cost, why this is genuinely $0/mo
| Line item | Cost |
|---|---|
| Cloudflare Workers | $0 on the free tier (100k requests/day) |
| Cloudflare KV | $0 on the free tier |
| Google Calendar API | $0, it is free to use |
| LLM (Gemini 1.5 Flash) | about $0.07 per 1M input tokens, so a few dollars a month at real volume |
A booking conversation is small. Most owners spend a dollar or two a month on the LLM and nothing else. Compare that to $20 to $50 every month, forever, for a tool you rent.
Week-one checklist
-
curlthe/chatendpoint, get a normal reply (Phase 1 works) - Ask "what do you have tomorrow afternoon?", the agent lists real open slots
- Say "book me for 2pm, my name is Alex", the event appears in your Google Calendar
- Block 2pm yourself by hand, ask again, the agent no longer offers 2pm
- Say "cancel my appointment", the event disappears and the slot frees up
- Try to book outside business hours, the agent refuses and offers a valid time
- Point one real channel (SMS or web widget) at
/chatand book from your phone
Troubleshooting
- The agent offers a slot that is already taken: you are not re-checking before
booking. Call
list_free_slotsagain insidebook_appointment, not just at the start. - Calendar calls return 401: the access token expired or the refresh exchange failed. Confirm the refresh token secret is set and you are caching the access token for under one hour.
- The LLM invents a time instead of calling a tool: your tool schema or system
prompt is weak. Tell it plainly it may only state availability that came from
list_free_slots, and never promise a time it did not book. - Double bookings slip through: add the right-before-write re-check, and treat the calendar as the single source of truth, not your session store.
- It feels robotic: give the system prompt your business name, hours, and two example replies in your voice. Tone is teachable.
Provenance
The architecture here, a single /chat Worker, an LLM tool-calling loop, and SDK-free
Google Calendar REST calls, is adapted from the MIT-licensed SMB Forge AI Scheduler:
github.com/linmichael123/smbforge-agentic-workflows.
This kit is our own step-by-step build of that idea, written so you can stand it up and
own it, not a copy of any product.