A chatbot that lets a busy founder manage their cal.com calendar through plain conversation — book events, see what's coming up, cancel, and reschedule — powered by Claude, with a web chat UI.
For the full end-to-end walkthrough of every layer, see docs/ARCHITECTURE.md.
Requires uv (brew install uv).
cp .env.example .env # add your cal.com + Anthropic API keys
uv run uvicorn app.main:appOpen http://localhost:8000 and chat. Server runs also write rotating logs to tmp/app.log
(gitignored) — tool timings, held/resolved confirmations, provider errors — for
after-the-fact debugging. Run the tests with:
uv run pytest| Variable | Description |
|---|---|
CAL_API_KEY |
cal.com API key (create one here) |
ANTHROPIC_API_KEY |
Anthropic API key (required when LLM_PROVIDER=anthropic) |
LLM_PROVIDER |
anthropic (Claude) or mock (deterministic, runs with no LLM key; see below) |
LLM_MODEL |
.env.example pins claude-haiku-4-5 (fast demo); unset for claude-opus-4-8 |
CAL_USERNAME |
Optional — resolved from your cal.com profile (/me) at startup if omitted |
TIMEZONE |
Optional IANA timezone, e.g. America/New_York — also resolved from /me |
CAL_API_BASE_URL |
Optional override for self-hosted cal.com (default https://api.cal.com/v2) |
Browser chat UI (app/static/index.html)
│ POST /api/chat → SSE stream POST /api/chat/confirm {action_id, approved}
│ (thinking/text deltas, live tool chips)
▼
FastAPI (app/main.py) — rate-limited per session
▼
Agent loop (app/agent/loop.py) ──── system prompt w/ live "now" (app/agent/prompts.py)
│ provider-agnostic tool-use loop, per-session history,
│ confirmation gate for destructive actions
├──► LLM provider (app/llm/) — Claude adapter or deterministic mock
└──► Tool dispatch (app/agent/tools.py)
▼
CalComClient (app/calcom/client.py) ──► cal.com v2 REST API
- Agent loop — sends the conversation + tool schemas to the LLM; executes any tool calls it returns (concurrently) against cal.com; feeds results back; repeats until the LLM answers in prose. Iterations, history length, and session count are all capped.
- Streaming —
/api/chatanswers as Server-Sent Events: the model's thinking (where the model supports it), reply text deltas, and each tool completion stream live; a terminaldoneevent carries the final structured payload. The confirm endpoint stays plain JSON — its reply is server-authored and instant. - Confirmation gate —
cancel_booking/reschedule_bookingnever execute off the LLM's decision alone. The call is frozen server-side and the UI shows a Confirm/Decline card — with the real booking's title, attendees, times (localized), and location fetched at proposal time — and only an explicit click runs it, with exactly the frozen arguments. A prompt-injected booking title can't cancel anything. - Tools —
list_bookings(cursor-paginated),list_event_types,get_available_slots,create_booking,cancel_booking,reschedule_booking. - LLM abstraction — the loop speaks only the neutral types in
app/llm/base.py(Message,ToolDef,ToolCall,LLMResponse), so the vendor is swappable. The Anthropic adapter (app/llm/anthropic.py, Claude Opus 4.8 with adaptive thinking) and the deterministicMockProvidersit behind the same protocol; the mock keeps the whole stack runnable and testable with no LLM API key. - cal.com client — thin async client over the v2 API. Note cal.com versions endpoints
individually via the
cal-api-versionheader; each method pins its documented version. With a key but noCAL_USERNAME/TIMEZONE, startup resolves both fromGET /me.
With LLM_PROVIDER=mock, the assistant understands simple deterministic phrasings:
What's on my calendar?What event types do I have?Show slots for event type 123Book event type 123 at 2026-06-12T10:00:00Z for ada@example.comCancel booking <uid>Reschedule booking <uid> to 2026-06-12T10:00:00Z
With the Claude provider these are free-form ("book a 30-min intro with a candidate Thursday afternoon"), with the model doing the date resolution and slot negotiation the system prompt describes.
- Sessions are in-memory (one per browser tab) and evicted oldest-first past a cap — fine for a demo, not multi-process safe. The rate limiter is in-process for the same reason.
- The system prompt embeds the current time each turn (so "tomorrow" stays correct in a long-lived server), which defeats prompt caching of the message history — a conscious correctness-over-cost choice at this scale.
- The adapter checks the Models API once per process to decide whether the configured model
supports adaptive thinking (e.g.
claude-haiku-4-5doesn't) — thinking is simply omitted where unsupported.
