An AI scheduling assistant that books appointments by calling typed tools, with a calendar that is the single source of truth, so the model can never hallucinate a booking or double-book a slot.
Chat with it in plain English ("book a haircut for Alex at 2pm on the 1st"), watch it call check_availability / book_appointment / list_bookings / cancel_booking, and see a live calendar update as it works. It runs fully offline with no API key using a deterministic mock model, and switches to a real LLM the moment you paste your own key.
This is the product brain behind Switchboard, a live AI receptionist I built, minus the telephony.
- LLM tool / function calling behind a clean, provider-agnostic interface, not glued to any one vendor's SDK.
- Anti-hallucination guardrails. The language model proposes; the calendar decides. A booking only exists if a guarded write succeeds; the model's prose can't conjure one.
- Typed tool contracts with Zod. One schema both (a) tells the model how to call a tool and (b) validates what comes back. Malformed calls are rejected with structured errors, not executed.
- Bring-your-own-key (BYO-key). The deployed demo asks the visitor for their key, keeps it in the browser session, and forwards it per request. No key is ever committed and the project owner incurs no cost.
- A real agent loop: dispatch tool calls, feed results back, repeat, with a hard step cap.
- Tested core, zero network. Every guardrail is covered by Vitest using a deterministic mock model.
npm testneeds no key and no internet.
flowchart TD
U[User] -->|chat message| AL[Agent loop<br/>runAgentTurn]
AL -->|messages + tool schemas| LLM{LlmClient}
LLM -->|mock mode| MOCK[MockLlmClient<br/>deterministic, offline]
LLM -->|live mode| REMOTE[RemoteLlmClient] --> API["/api/llm proxy"] --> OAI[OpenAI-compatible API<br/>key from request header]
LLM -->|tool calls| DISP[dispatchTool]
DISP -->|Zod validation| ZOD[Tool schemas]
DISP -->|guarded writes| CAL[(Calendar<br/>source of truth)]
CAL -->|tool results| AL
CAL --> VIEW[Live calendar view]
AL -->|final reply| U
Two paths, one contract. Everything the agent needs from a model is the LlmClient interface (complete(messages, tools) -> { content, toolCalls }). Three implementations satisfy it:
| Implementation | Used for | Network? | Key? |
|---|---|---|---|
MockLlmClient |
tests + the "try it" demo | no | no |
ScriptedLlmClient |
tests that need an exact model trajectory | no | no |
createOpenAiClient |
the real provider (OpenAI, Groq, OpenRouter, local gateways…) | yes | yes |
Anthropic / Claude as an alternative: Claude's Messages API maps cleanly onto the same shape: a system prompt, alternating turns,
toolsdescribed with JSON Schema, andtool_use/tool_resultcontent blocks. ImplementingLlmClientagainst@anthropic-ai/sdkis a drop-in: translate ourChatMessage[]to Claude content blocks and maptool_useback toToolCall. Nothing else in the codebase changes.
The agent loop (src/lib/agent.ts) enforces four rules:
- The calendar is the only source of truth. A booking is created exclusively by a successful
book_appointmenttool call, and the UI renders the calendar, never the model's claims. If the model says "you're booked" without a successful tool result, nothing actually changed. - No double-booking.
book_appointmentre-checks availability insideCalendar.book(). A slot that overlaps an existing booking is refused with aconflictreason, regardless of what the model wants. (Back-to-back appointments that only touch at the boundary are allowed; intervals are half-open.) - Every booking needs a name. Enforced twice: by the Zod schema (
name: z.string().min(1)) and again by the calendar. - Bounded work.
maxStepscaps model↔tool round-trips so a confused model can't loop forever.
A failed tool call isn't an exception; it's a structured result fed back to the model, which then apologises and offers real alternatives. That refusal path is the most important test in the suite.
- Provider-agnostic
LlmClientinstead of an SDK dependency. Keeps vendor lock-in out of the core, makes the loop trivially testable (swap in a mock), and means "add Anthropic" is one file. - Zod as the single source of truth for tools.
zod-to-json-schemaderives the schema the model sees from the same Zod object that validates the model's output, so they can't drift apart. - Guardrails live in the calendar/dispatch layer, not the prompt. Prompts are advisory; code is enforcing. The mock model is intentionally naive (it tries to book without checking) specifically to prove the safety net is structural, not a matter of the model's good manners.
- Timezone-safe time math. Wall-clock times (
YYYY-MM-DDTHH:mm) are mapped onto integer minutes viaDate.UTC, so conflict detection is exact and tests never go flaky across machines/timezones. - BYO-key via a thin server proxy. The browser holds the key in
sessionStorageand sends it on thex-api-keyheader;/api/llmuses it for exactly one call and never logs or persists it. No secret ever touches the repo.
git clone https://github.com/millerharry/scheduler-agent.git
cd scheduler-agent
npm install
npm run dev # http://localhost:3000The app opens in mock mode with no key required. Type a request or click an example prompt and watch the calendar update.
To use a real model, click Set API key, paste an OpenAI-compatible key, and the same agent loop runs against the live provider. Your key stays in the browser session and is sent per request.
For a private instance where you supply the key, copy .env.example to .env.local and set OPENAI_API_KEY (and optionally OPENAI_BASE_URL / OPENAI_MODEL). The public demo doesn't need this; it relies on BYO-key.
- Push this repo to GitHub and import it at vercel.com/new (zero config; it's a standard Next.js App Router app).
- Set no environment variables to keep the public BYO-key behaviour (visitors supply their own key). Only set
OPENAI_API_KEYif you intend to pay for a shared key. - Deploy. Mock mode works immediately with no configuration.
npm test # Vitest, no network, no key
npm run typecheck # tsc --noEmit (strict)
npm run lint # next lint
npm run build # production buildThe suite (src/lib/**/*.test.ts) covers the core logic:
- Calendar conflict logic: booking a free slot, refusing an overlap, allowing back-to-back, business-hours bounds, cancel, availability, snapshot round-trip.
- Zod tool-schema validation: missing name, unknown service (enum), extra fields rejected by
.strict(), unknown tool name. - The agent loop end-to-end with
MockLlmClient: availability, booking, list/cancel, multi-turn. - Guardrail tests: the model tries to book a taken slot and is refused (via both the heuristic mock and a fully scripted model), a booking with no name is turned into a "what's the name?" question, and a looping model is stopped at
maxSteps. - OpenAI wire mapping: request shape (model, tools, bearer token) and
tool_callsparsing, all with an injected fakefetch(no network).
src/
lib/
agent.ts # the tool-calling loop + system prompt + guardrails
calendar.ts # in-memory calendar, conflict logic (source of truth)
tools.ts # Zod tool schemas + JSON-schema export + dispatchTool
services.ts # bookable services and their durations
time.ts # timezone-safe wall-clock time math
persistence.ts # localStorage load/save for the browser demo
llm/
types.ts # the provider-agnostic LlmClient contract
mock.ts # MockLlmClient - deterministic offline "model"
scripted.ts # ScriptedLlmClient - replay canned responses in tests
openai.ts # createOpenAiClient - real OpenAI-compatible HTTP
remote.ts # RemoteLlmClient - browser -> /api/llm (BYO-key)
app/
page.tsx # landing + Playground
api/llm/route.ts# BYO-key proxy (key from header, never persisted)
components/ # Playground, ChatPanel, CalendarView, KeyDialog, voice hook
I built and run Switchboard, an AI receptionist that answers phone calls, understands what the caller wants, and takes action (including booking). The hardest, most interesting part isn't the telephony; it's making an LLM act reliably: call the right tool, with valid arguments, and never confirm something that didn't actually happen. A receptionist that cheerfully tells a caller they're booked when they aren't is worse than useless.
scheduler-agent is that brain extracted into a small, readable, fully-tested repo: the tool-calling loop, the typed-tool contract, and, most importantly, the guardrails that keep the model honest. It's the pattern I use in production, with the safety properties pinned down by tests instead of hope.
MIT © 2026 Harry Miller