A Python/LangGraph agent that turns natural-language scheduling requests ("set up a recurring standup with the team, avoiding Fridays") into validated, RFC 5545-compliant calendar events written to a live WebCalendar instance via MCP — with human approval before every write, an eval suite for RRULE/DST correctness, and full tracing.
- docs/PRD.md — goals, requirements, decisions, risks
- docs/ARCHITECTURE.md — system design, agent graph, model provider abstraction, MCP tool surface, eval design
- docs/SCHEMA_AUDIT.md — WebCalendar recurrence ↔ RRULE
- docs/CHAOS.md — MCP fault tolerance (k5n-mcp-hub)
- docs/TASKS.md — phased task list
flowchart LR
U[NL request] --> P[parse_intent]
P --> G[gather_context]
G --> PR[propose]
PR -->|query / error| R[respond]
PR -->|create/update/delete| H{{human_approval\ninterrupt}}
H -->|reject + feedback| P
H -->|approve| E[execute]
E --> V[verify]
V --> R
G -. MCP .-> WC[(WebCalendar mcp.php)]
E -. MCP .-> WC
V -. MCP .-> WC
- Orchestration: LangGraph — stateful graph, SQLite checkpointing, a human-in-the-loop interrupt before every write, and a reject→replan loop.
- Models: pluggable via
MODEL_PROVIDER— Anthropic API key, OpenRouter, an Anthropic Pro/Max plan via theclaudeCLI, or a local model through ollama / LM Studio (OpenAI-compatible, no API key). Structured output uses nativejson_schemawhere the provider supports it and falls back to a provider-agnostic validate-and-repair loop otherwise, so no model needs tool-calling or MCP support — the graph does orchestration in code and the model only maps natural language to a validatedScheduleProposal. - Correctness: every recurrence is built and validated against the
exact subset WebCalendar can store/expand (a Python twin of the PHP
validator), with DST-correct expansion previews via
dateutil. - Backend: WebCalendar's MCP server (
mcp.php), extended with availability, conflict-detection, and recurrence tools. - Related repos: webcalendar, k5n-mcp-hub, php-icalendar-core
uv sync
cp .env.example .env # then fill in MODEL_PROVIDER + its key, MCP_URL, MCP_TOKEN
uv run scheduling-agent # or: uv run python -m scheduling_agentYou describe what to schedule; the agent plans, shows you a proposal
(with the recurrence expanded and any conflicts flagged), and waits
for your approval before writing anything. Type quit to exit.
Web UI (same graph, POST /schedule + POST /approve + a chat page):
uv run scheduling-agent-web # http://localhost:8000Or the whole stack in Docker (agent + WebCalendar; add --profile chaos
for k5n-mcp-hub fault injection):
docker compose up --buildEverything runs offline in tests via an in-memory calendar and a fake model, so no API key or live instance is needed to develop.
Structured JSON logs carry a per-conversation correlation_id. Set
LANGSMITH_TRACING=true + LANGSMITH_API_KEY to trace every graph run
in LangSmith (the startup line reports whether tracing is on).
A golden dataset (src/scheduling_agent/evals/cases.yaml, 20 cases —
recurrence, avoid-Fridays constraints, BYSETPOS, DST spring-forward/
fall-back, update/delete/query) is scored by deterministic checks
(RRULE validity, occurrence expansion, weekday constraints, DST
local-hour, conflict awareness).
uv run python -m scheduling_agent.evals --mode reference # no LLM; runs in CI
uv run python -m scheduling_agent.evals --mode agent # measures a real providerReference mode gates CI (any regression fails) and uploads a JSON + markdown report artifact. Agent mode runs the real graph against your configured provider to measure model quality.
All credentials come from the environment (.env, gitignored); see
.env.example for the variable names (no values).
Live-calendar URLs, tokens, and API keys are never committed.
| Variable | Purpose |
|---|---|
MODEL_PROVIDER |
anthropic | openrouter | claude-subscription | ollama | lmstudio |
ANTHROPIC_API_KEY / OPENROUTER_API_KEY / CLAUDE_CODE_OAUTH_TOKEN |
credential for the selected provider (local providers need none) |
MODEL_NAME |
model tag/id (e.g. qwen2.5:32b for ollama); optional |
OLLAMA_BASE_URL / LMSTUDIO_BASE_URL |
override the local endpoint (defaults :11434/v1 / :1234/v1) |
MCP_URL / MCP_TOKEN |
WebCalendar mcp.php endpoint + API token |
Local models & structured output:
ollama,lmstudio, andopenrouteruse nativejson_schemastructured output when the model supports it — the model is constrained to emit a validScheduleProposal, which is far more reliable than free-text. If the model/server doesn't support it, the agent automatically falls back to the validate-and-repair loop (the subscription CLI always uses the loop). Support is per model: use a structured-output-capable one — e.g. Qwen2.5, Llama-3.1/3.2, Mistral-Nemo, Command-R, Hermes (in LM Studio, look for the "tool use" badge). Small/base models may struggle with the schema and RRULE reasoning regardless — measure any model withpython -m scheduling_agent.evals --mode agent.
- Validate recurrence before sending.
rrule.pyis a Python twin of WebCalendar's server-side validator, derived from a schema audit ofwebcal_entry_repeats. The agent never proposes a rule the backend can't store/expand. - HITL is a real graph interrupt. Writes pause at a LangGraph
interrupt; state is checkpointed to SQLite, so an approval survives a process restart (proven in tests). Rejections loop back and replan. - Native structured output, with a universal fallback. Providers that
support it are constrained via native
json_schema; the rest (e.g. the Pro/Max subscription CLI) use a provider-agnostic validate-and-repair loop, so every backend works on equal footing — and provider differences become a measured eval number, not a bug class. - GMT at the tool boundary. The scheduling MCP tools operate in the storage frame; the agent owns local↔GMT (with DST-correct expansion), keeping the PHP side simple.
- Direct JSON-RPC client.
mcp.php's HTTP transport is custom JSON-RPC (not standard-MCP), so a small httpx client fits better thanlangchain-mcp-adapters. Every transport/protocol fault is wrapped into oneMcpError(chaos-tested). - Evals split for a keyless CI. Deterministic scorers gate CI over a
golden dataset (20/20); a real provider is measured opt-in via
--mode agent.
Known v1 limitations (documented): conflict checks don't expand recurring
occurrences past the base date, so a proposed slot is only checked against the
first instance of an existing series. Slated for a backend follow-up and
quantified by live-instance eval runs. (The earlier untimed one-off create
limitation is resolved — add_event now takes a time, WebCalendar
#670.)
uv sync # create the venv (Python 3.12) and install deps
scripts/install-hooks.sh # one-time: run the gate automatically on git pushscripts/ci.sh is the single source of truth — GitHub Actions runs the
exact same script, so local checks and CI can't drift.
scripts/ci.sh # full gate: ruff, format, mypy, bandit, pytest+coverage
scripts/ci.sh test # a single stage: lint | format | type | security | testOnce hooks are installed, the full gate also runs on every git push
and blocks it on failure (bypass in a pinch with git push --no-verify).
MIT © 2026 Craig Knudsen.