Skip to content

millerharry/scheduler-agent

Repository files navigation

Scheduler Agent

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.


What this demonstrates

  • 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 test needs no key and no internet.

Architecture

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
Loading

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, tools described with JSON Schema, and tool_use / tool_result content blocks. Implementing LlmClient against @anthropic-ai/sdk is a drop-in: translate our ChatMessage[] to Claude content blocks and map tool_use back to ToolCall. Nothing else in the codebase changes.

The guardrails (why it can't hallucinate a booking)

The agent loop (src/lib/agent.ts) enforces four rules:

  1. The calendar is the only source of truth. A booking is created exclusively by a successful book_appointment tool 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.
  2. No double-booking. book_appointment re-checks availability inside Calendar.book(). A slot that overlaps an existing booking is refused with a conflict reason, regardless of what the model wants. (Back-to-back appointments that only touch at the boundary are allowed; intervals are half-open.)
  3. Every booking needs a name. Enforced twice: by the Zod schema (name: z.string().min(1)) and again by the calendar.
  4. Bounded work. maxSteps caps 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.


Key technical decisions

  • Provider-agnostic LlmClient instead 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-schema derives 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 via Date.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 sessionStorage and sends it on the x-api-key header; /api/llm uses it for exactly one call and never logs or persists it. No secret ever touches the repo.

Getting started

git clone https://github.com/millerharry/scheduler-agent.git
cd scheduler-agent
npm install
npm run dev          # http://localhost:3000

The 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.

Optional: server-side key (private deployments)

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.

Deploy to Vercel

  1. Push this repo to GitHub and import it at vercel.com/new (zero config; it's a standard Next.js App Router app).
  2. Set no environment variables to keep the public BYO-key behaviour (visitors supply their own key). Only set OPENAI_API_KEY if you intend to pay for a shared key.
  3. Deploy. Mock mode works immediately with no configuration.

Testing

npm test          # Vitest, no network, no key
npm run typecheck # tsc --noEmit (strict)
npm run lint      # next lint
npm run build     # production build

The 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_calls parsing, all with an injected fake fetch (no network).

Project structure

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

Why I built this

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.


License

MIT © 2026 Harry Miller

About

An AI scheduling assistant that books appointments via LLM tool-calling, with structural anti-hallucination guardrails. Bring-your-own-key demo.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages