A LangGraph state-machine that orchestrates a Triage Agent and a Booking Specialist to turn free-text chat into validated, persisted calendar bookings — with real negotiation on conflicts, not silent failure.
Live demo: <ADD_YOUR_DEPLOYED_URL_HERE>
Most "agent" scheduling demos wire everything through a single LLM tool-calling
loop and hope the model remembers to validate dates. That's fragile: an LLM
will happily call reserve_slot(date="tomorrow") and let a downstream system
choke on it. This implementation treats date/time normalization and
routing as deterministic code, and only asks the LLM to do what LLMs are
actually good at — free-text intent classification and conversational replies.
The result is a system that behaves identically on every run given the same
input, which is what you want from something that touches a calendar and
sends confirmations.
┌────────────┐
user message ───► │ Triage │
│ Agent │
└─────┬──────┘
intent=general │ intent=booking
│ │
▼ ▼
┌────────────┐ ┌──────────────────┐
│ General │ │ Booking Specialist │
│ Reply │ │ (extract_slot) │
└─────┬──────┘ └─────────┬──────────┘
│ missing fields?
▼ │ │
END yes │ │ no
▼ ▼
┌──────────┐ ┌────────────────────┐
│ ask_missing│ │ check_availability │
└─────┬────┘ └─────────┬───────────┘
│ free │ taken
▼ ▼ ▼
END ┌──────────┐ ┌────────────┐
│ reserve │ │ negotiate │
└────┬─────┘ └─────┬──────┘
success │ │
▼ ▼
┌─────────┐ END
│ notify │
└────┬────┘
▼
END
This is implemented as a langgraph.graph.StateGraph in
agent/graph.py. Every path that ends in END is a real
conversational turn boundary — the graph pauses, hands control back to the
user, and the next message resumes the same thread from its checkpoint
(see Persistence below), so a partially-collected booking or an open
negotiation survives across messages and browser refreshes.
| Agent | Responsibility | File |
|---|---|---|
| Triage Agent | Classifies each incoming message as general vs booking intent. Once a booking is mid-collection or mid-negotiation, it keeps routing to the Booking Specialist so a bare "3pm" reply isn't misclassified as small talk. |
agent/graph.py::triage_node |
| Booking Specialist | Normalizes relative dates/times, tracks partially-collected slot fields (date, time, email, name) in graph state, drives the three tools, and negotiates alternatives on conflict. |
agent/graph.py::extract_slot_node, check_availability_node, reserve_node, notify_node, negotiate_node |
Both are valid; this repo picks explicit state-machine routing (agent/tools.py
tools are still LangChain @tool-decorated, so you can swap in a
ToolNode + model-driven loop with minimal changes if you prefer that
style — the tools don't care who calls them). Explicit routing was chosen
because it makes date validation, negotiation retry limits, and race-condition
handling on reserve_slot fully deterministic and easy to unit test, which
matters more here than flexibility of phrasing.
utils/date_utils.py resolves natural language into strict values before
any tool executes:
"tomorrow","today","day after tomorrow"→ relative offset from the server's current date"next Monday","Friday"→ resolved weekday, correctly skipping the current day for"next X"phrasing"July 15","15/07/2026","2026-07-15"→ parsed and validated as real calendar dates (invalid dates like Feb 30 are rejected, not silently accepted)"3pm","15:00","noon","morning"→ normalized 24hHH:MM- A resolved date that lands in the past is discarded, forcing the Booking Specialist to re-ask rather than book yesterday.
- If the requested slot is taken,
negotiate_nodelooks up real open slots for that date and offers up to 5 concrete alternatives — never a bare "sorry, try again." - If a date is fully booked, it asks for a different date instead of looping on an empty slot list.
- A hard retry cap (
MAX_NEGOTIATION_RETRIES = 3) prevents infinite negotiation loops; after that it explicitly asks the user to restart with a new date. - Race condition handling:
reserve_slotre-validates availability under a lock at write time (not just at the earliercheck_availabilityread), because two concurrent users can otherwise both "see" the same open slot. If the write loses the race, the graph re-checks availability and routes back intonegotiate_nodeinstead of raising. send_booking_notificationnever raises on webhook failure — a confirmation-delivery hiccup is logged but does not unwind an already confirmed reservation.
All three live in agent/tools.py as LangChain @tool
functions backed by db/database.py:
check_availability(date)— reads from a deterministically-seeded in-memory mock calendar (so the same date always has the same conflicts across runs, minus whatever's actually been booked in SQLite) merged with real bookings.reserve_slot(date, time, email, name, thread_id)— inserts into a SQLitebookingstable with aUNIQUE(date, time)constraint as a second line of defense against double-booking, on top of the lock-protected availability re-check.send_booking_notification(email, details)— POSTs a confirmation payload to a configurable webhook (NOTIFICATION_WEBHOOK_URL, e.g. a Webhook.site or Pipedream RequestBin URL) to simulate an email/WhatsApp confirmation, and logs every attempt (success or failure) to anotificationstable for audit.
Conversation state is checkpointed per thread_id using LangGraph's
SqliteSaver (agent/graph.py::build_graph), storing the full graph state
(messages, in-progress slot fields, booking status, retry counts) to
data/checkpoints.sqlite. Confirmed bookings are additionally written to a
separate data/bookings.db for durable business records independent of
conversational state.
In the Streamlit UI, the thread ID is carried in the URL (?thread=<id>), so
refreshing the page resumes the exact same in-progress conversation —
partial bookings, open negotiations, everything — rather than starting over.
Use "Start a new conversation" in the sidebar to get a fresh thread.
utils/llm.py tries providers in order — Groq → Gemini → OpenRouter —
and falls back to a deterministic keyword/regex classifier if none are
configured or all requests fail. This means the app is fully testable and
gradeable with zero API keys set, while still using a real LLM for intent
classification and conversational replies when keys are available. This
mirrors the multi-provider fallback pattern used elsewhere in my projects
(Groq → Gemini → OpenRouter) for resilience against rate limits/outages on
any single provider.
scheduling-assistant/
├── agent/
│ ├── graph.py # StateGraph assembly, nodes, routing
│ ├── state.py # AgentState / BookingSlot TypedDicts
│ └── tools.py # check_availability, reserve_slot, send_booking_notification
├── db/
│ └── database.py # SQLite bookings/notifications + mock availability
├── utils/
│ ├── date_utils.py # relative date/time/email resolution
│ └── llm.py # multi-provider LLM wrapper + fallback
├── app.py # Streamlit chat UI, thread persistence via URL
├── requirements.txt
├── render.yaml # Render free-tier deploy config
├── Procfile # fallback start command
├── runtime.txt
└── .env.example
git clone <this-repo-url>
cd scheduling-assistant
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # fill in whichever keys you have — all optional
streamlit run app.pyOpen the URL Streamlit prints (default http://localhost:8501).
See .env.example for the full list. Nothing is required to
run the app — every value degrades gracefully:
| Variable | Required? | Effect if unset |
|---|---|---|
GROQ_API_KEY / GEMINI_API_KEY / OPENROUTER_API_KEY |
No | Falls back to rule-based intent classification and templated replies |
NOTIFICATION_WEBHOOK_URL |
No | Notification is logged locally instead of POSTed externally |
SQLITE_DB_PATH, CHECKPOINT_DB_PATH |
No | Defaults to data/bookings.db, data/checkpoints.sqlite |
No API keys are committed to this repository. .env is git-ignored;
only .env.example (with empty values) is tracked.
Try this sequence in the chat:
> Hi, what can you do?
> I want to book an appointment
> tomorrow at 10am
> my email is you@example.com
To see negotiation in action, book a slot, then immediately try to book the same date/time again in a different thread (new tab / "Start a new conversation") — the seeded mock calendar plus the just-created real booking guarantees a conflict on the busier half-hour marks of any given day.
- Push this repo to GitHub.
- Go to share.streamlit.io → "New app" → point
at
app.pyon your repo/branch. - Add your env vars under Settings → Secrets in TOML format, e.g.:
GROQ_API_KEY = "..." NOTIFICATION_WEBHOOK_URL = "https://webhook.site/your-id"
- Deploy. Note: Streamlit Cloud's filesystem is ephemeral on redeploy, so
data/*.sqlite*resets on redeploys (not on normal usage/sleep-wake).
- Push to GitHub, then in Render: New → Blueprint, point at this repo —
render.yamlis already configured (free web service + persistent disk mounted atdata/so SQLite files survive restarts). - Set the same env vars as secrets in the Render dashboard.
- Render builds with
pip install -r requirements.txtand starts withstreamlit run app.py --server.port $PORT --server.address 0.0.0.0.
- Create a new Space → SDK: Streamlit.
- Push this repo's contents to the Space's git remote.
- Add secrets under Settings → Repository secrets.
- HF Spaces persists
/dataunder a persistent Space disk on paid tiers only; on the free tier, pointSQLITE_DB_PATH/CHECKPOINT_DB_PATHat the default relative path — it will reset on Space restarts, same caveat as Streamlit Cloud's free tier.
- The mock calendar seeds "taken" slots deterministically from a hash of the date string — it's not a real calendar backend, intentionally, per the assignment spec ("mocked but functional").
- Single business-hours calendar (09:00–18:00, 30-minute slots by default,
configurable via
.env) rather than multi-calendar/timezone support. - The webhook notification is fire-and-forget with a logged outcome, not a retried delivery queue — acceptable for a "mock webhook trigger" per spec.