The problem
Picture a plumber who gets messages like this one:
I have a water leak, I’d like an appointment tomorrow afternoon.
It looks like the perfect job for a chatbot. In practice, a “pure” chatbot connected to a language model tends to fail exactly where it can’t afford to: it invents free time slots, gets “tomorrow” or “Thursday” wrong, makes up the customer’s phone number and, worst of all, says “booking confirmed” without having saved anything.
Local Booking Agent is the prototype I built to figure out how to avoid those mistakes. It’s an Italian-language booking assistant that runs entirely on a Mac. A local language model, qwen3:8b served by Ollama, understands requests written in natural language, while every decision that matters (which slots are free, whether the data is valid, what gets written to the calendar) is made by deterministic code and verified.
The whole project comes down to one idea: conversational AI becomes reliable when you take away its power to decide.
Before starting, I set myself a few requirements:
- no cloud, because we’re dealing with personal data;
- fully offline operation;
- explicit confirmation from the customer before anything is written;
- no double bookings, not even with two customers at the same instant;
- understandable behavior even when the model doesn’t respond.
The prototype’s “client” is a fictional plumber and all the data is synthetic. The interface is in Italian, like the customers it’s meant for.

The project by the numbers
| Item | Value |
|---|---|
| Backend (Python) | 13 files, about 1,880 lines |
| Frontend (HTML, CSS and JS, no framework) | 9 files, about 2,440 lines |
| Automated tests | 74 tests, about 800 lines, under 1 second, without Ollama |
| Python dependencies | 5: fastapi, uvicorn, httpx, pydantic, pytest |
| Frontend dependencies | none: no CDN, no external fonts, 35 local SVG icons |
| Model | qwen3:8b via Ollama, about 5.2 GB |
| Hardware | Mac mini M4 with 16 GB of RAM |
| Database | SQLite in WAL mode, a single application table |
The choices, and why
- Local Ollama: no data leaves the computer, the demo works without Internet, and each request costs nothing.
qwen3:8b: chosen with a benchmark. Of the models I tried, it’s the only one that’s reliable at tool calling in Italian (more on that below).- FastAPI and Pydantic: typed validation of everything coming in, meaning HTTP requests, the arguments chosen by the model, and Ollama’s responses.
- SQLite in WAL mode: there’s one process and one technician.
BEGIN IMMEDIATEis enough to serialize writes, and a database server wouldn’t add anything. - NDJSON streaming: the browser receives the agent’s steps in real time, such as “date interpreted” or “calendar checked”.
- No LangChain or agent frameworks: the agent loop is about 100 readable lines. It’s easier to explain, test and verify.
- No frontend framework: no build, no dependencies, works offline, and allows a very strict Content-Security-Policy.
Architecture
Browser
├─ Customer page /
└─ Technician area /tecnico
│
▼
┌──────────────── FastAPI, on the Mac ─────────────┐
│ HTTP routes + security headers │
│ ├─ TechAuth PIN → cookie │
│ └─ Chat sessions (with expiry) │
│ │ │
│ ▼ │
│ Agent ◄────────────► Ollama 127.0.0.1 │
│ │ │ qwen3:8b │
│ │ └─► dates_it (Italian dates) │
│ ▼ │
│ ToolExecutor (3 tools + Pydantic) │
│ ▼ │
│ CalendarService (rules + atomic writes) │
│ ▼ │
│ SQLite (WAL) │
└──────────────────────────────────────────────────┘
The main modules are few and small:
| Module | Lines | Role |
|---|---|---|
calendar_service.py | 398 | working hours, free slots, draft validation, writing and verification |
agent.py | 324 | prompt, model ↔ tools loop, deterministic paths, output filters |
tools.py | 274 | the 3 tools exposed to the model, Pydantic schemas, anti-fabrication checks |
main.py | 204 | thin routes, security headers, error handling |
dates_it.py | 126 | “domani” (tomorrow), “giovedì” (Thursday), “il 25”, “25/10”, plus detection of vague periods |
llm.py | 103 | HTTP client for Ollama, timeouts, response validation |
db.py | 98 | schema, constraints, synthetic data |
auth.py | 82 | from PIN to session token, attempt lockout |
The key principle: who decides what
| The model does | The backend decides and verifies |
|---|---|
| Understands the request: the problem, the type of job, preferences | Resolves relative dates using the Europe/Rome clock |
| Chooses which tool to call | Validates every argument: types, formats, allowed values |
| Asks for missing details in its own words | Computes free slots by querying the database |
| Replies briefly and politely | Rejects dates never mentioned, details never written by the customer, taken slots |
| Generates the summary and the outcome in code | |
| Saves only after explicit confirmation, inside a transaction, then reads it back to verify |
The model never has access to the database: it can only ask the code to do it. It sees no SQL, files or shell. It has exactly three tools:
cerca_disponibilita(data, tipo_intervento, fascia)(“find availability”): reads the free slots.prepara_prenotazione(...)(“prepare booking”): creates an in-memory draft and writes nothing.conferma_prenotazione()(“confirm booking”): accepted only if the customer’s latest message is an explicit “yes”.
If the model tries to call a tool that doesn’t exist (in the tests I use esegui_sql, “run SQL”), the call is rejected. And there’s no tool for reading other customers’ appointments: there’s nothing to get around, because the function simply isn’t there.
How a booking flows
Customer "Water leak, tomorrow afternoon"
│
▼
Backend "tomorrow" → exact date (Europe/Rome)
│
▼
Model cerca_disponibilita(...)
│
▼
Backend validates the arguments → SELECT on the calendar
│
▼
Customer sees only slots that are actually free
picks a slot, writes name, phone, address
│
▼
Model prepara_prenotazione(...)
│
▼
Backend did the customer write these details? was the date given?
is the slot still free? → draft + summary generated by code
│
▼
Customer "Confirm" (no model call)
│
▼
Backend BEGIN IMMEDIATE → re-check → INSERT → COMMIT
read back with a new connection and compare
│
▼
Customer "Booked", only if the verification succeeds
Here’s what it looks like. The model only offers slots that came from cerca_disponibilita, and they show up as buttons inside the conversation. Once a slot is picked, a short form collects name, phone and address.

When the details are complete, the backend generates the summary with an explicit notice: the appointment is not saved yet.

Confirmation always goes through a deterministic path: it takes between 0.0 and 0.1 seconds and makes no model calls. A booking is confirmed when the database confirms it, not when the model says so.

The guarantees, one by one
This is the part of the project that taught me the most. For each typical chatbot failure, I looked for a countermeasure in code:
| Risk | Countermeasure |
|---|---|
| Invents free slots | Slots only come from cerca_disponibilita; a time mentioned in the text that didn’t come from the calendar gets flagged |
| Gets “tomorrow” or “Thursday” wrong | The backend resolves the date and passes it to the model as a note; a draft with a different date is rejected (data_non_coerente) |
| Picks a date when the customer is vague (“next week”) | The vague period is detected and the search is rejected (data_ambigua): the model has to ask for the day |
| Books a day never mentioned | The draft’s date must have been written by the customer or offered by the calendar (data_non_indicata) |
| Invents name, phone or address | They must appear in the customer’s messages (dati_non_forniti) |
| Says “booking confirmed” without saving anything | The text is intercepted and replaced by the backend |
| Confirms without the customer’s “yes” | The tool call is rejected (conferma_mancante) |
| Malformed or non-JSON arguments | Pydantic schemas return a structured error and the model corrects itself |
| Malformed Ollama response or timeout | The response is discarded, the customer sees a clear message and nothing is written |
| Two customers on the same slot | BEGIN IMMEDIATE, a re-check inside the transaction and a partial UNIQUE index: in a test with 12 threads, only one succeeds |
| Double click on “Confirm” | Idempotency through a request_id |
| Prompt injection (“ignore the rules…”, fake system notes) | There are no dangerous tools, confirmation and outcome are deterministic, and fake [Nota di sistema (“system note”) markers written by the customer are neutralized |
Customer details are checked too. Italian phone numbers are validated (10-digit mobiles, landlines from 6 to 11 digits), and obviously fake numbers made of repeated or sequential digits are rejected. First name, last name and street number are required, and capitalization is normalized: “mario d’angelo” becomes “Mario D’Angelo”.
In the test below I typed 333 333 3333 as the phone number. It has the format of a mobile number, but the repeated digits make it look made up: the backend flags it in the checks (“number to double-check”) and the model asks for it again, without creating any draft.

The calendar has precise rules: Monday to Friday 8–12 and 14–18, Saturday 8–12, closed on Sunday. Jobs last 60 or 120 minutes, at least one hour’s notice is required, bookings can be made up to 60 days ahead, and a two-hour job can’t span the lunch break.
Choosing the model: why 8B and not 4B
I wrote a benchmark (scripts/benchmark.py) with realistic scenarios: a full booking, an already-taken slot, missing details, and a request for other customers’ data. All on the same Mac mini M4.
| Model | Checks passed | Notes |
|---|---|---|
qwen3:8b | 18/18 (3 runs) | reliable tool calling in Italian |
qwen3:4b-instruct | 9/18 | faster, but offers slots without checking the calendar |
qwen3:4b | 3/18 | “reasons” in English inside the reply and ignores think:false |
The smaller model is faster, but less reliable at calling tools. On an agentic task, reliability in calling tools matters more than speed. A model that answers in half the time but offers a made-up slot is worse than a slower one that always checks the calendar.
These are the latencies with qwen3:8b. They come from a handful of runs on a single Mac, so they’re indicative, not a rigorous benchmark:
| Turn type | Median |
|---|---|
| Confirmation (deterministic path) | about 0.0 s |
| Turn with 1 model call | about 3 s |
| Turn with a calendar lookup (2 calls) | 8–12 s |
| Generation speed | about 16–19 tokens/s |
| Cold load and warm-up | 9–12 s, once |
Across 21 calls I also saw a single spike of 25.7 seconds. To get to these numbers I turned off reasoning (think:false), lowered the temperature to 0.1, kept the model in memory for 60 minutes, made the system prompt stable (so Ollama reuses its cache) and warm it up at startup. Summary, outcome and confirmation are generated by code, so they cost no model calls at all.
Local and offline, verified
Saying “it works offline” is easy; proving it, a bit less so. The scripts/offline_check.sh script runs Ollama, the benchmark and the web server inside macOS’s sandbox-exec, with a profile that blocks every outbound connection except to localhost.
First, the script proves the sandbox really blocks the Internet by trying to reach example.com. Then it runs the benchmark (6/6), checks that the page is served, goes through a full conversation up to a verified confirmation, restarts the server and checks that the booking is still there. The last run passed on October 24, 2026.
The Internet is only needed for installation (Homebrew and pip) and to download the model.
Security
What’s implemented and covered by tests:
- server-side authorization on every API that touches personal data or restricted operations;
- the technician’s PIN lives outside the code, in
.env, and is generated randomly on first launch. It’s compared in constant time and, after 5 wrong attempts, access is locked for 5 minutes; - after login, a random token in an
HttpOnly,SameSite=Strictcookie, scoped to/api;

- public APIs are kept to a minimum: only free/taken slots and model status;
- no stack trace ever reaches the client, and
/docsand/openapi.jsonare disabled; - security headers: a strict Content-Security-Policy,
X-Frame-Options: DENY,nosniff,no-referrerandCache-Control: no-storeon the APIs; - chat sessions with unguessable IDs, an expiry and a maximum count;
- the Ollama client uses
trust_env=False, so it ignores system proxies and only talks over loopback.
And what’s not there, because it’s only fair to say so: no TLS (it’s plain HTTP), a single shared PIN, no audit log, in-memory sessions only, an unencrypted database, no customer identification, and no GDPR assessment or DPIA.
The interface
The whole interface uses a single design system in base.css: color tokens, system typography, spacing on a 4 px grid and reusable components.
The customer follows a four-step indicator: Problem, Time, Details, Confirmation. Free slots show up as buttons inside the conversation, details go into a short form, and the summary clearly says “not saved yet”. Under every reply the system checks are visible, such as “date interpreted” or “calendar checked”: the customer sees what was verified instead of having to take it on trust.
The technician gets the next job front and center, a 14-day agenda refreshed every 3 seconds, job states (scheduled, in progress, done, cancelled), a detail view, cancellation with double confirmation, and a sound notification for new bookings.

On mobile, the customer has a bottom bar and the technician gets a panel that slides up from the bottom.
Testing an LLM system without an LLM
The 74 tests run in under a second and don’t use Ollama. The model is simulated by a FakeLLM that returns pre-programmed replies and tool calls, the HTTP client is tested with httpx.MockTransport, and the clock is fixed.
Among other things, they cover: a valid request, missing details, an ambiguous date, no available slots, a slot taken between draft and confirmation, concurrency with 12 threads, Ollama being down, timing out or returning a malformed response, invalid model arguments, unauthorized access and attempt lockout, prompt injection, security headers, and the absence of external URLs in the frontend.
The idea is this: you test the contract, not the AI. I don’t care about checking the model’s “creativity”; I care about what happens when it calls tool X with arguments Y. The real model’s behavior I measure separately, with the benchmark. Keeping the two apart makes the tests fast, deterministic and useful on every change.
Limitations
It’s a prototype with synthetic data and it’s not production-ready. In particular:
- there’s a single technician, with fixed hours and whole-hour slots; no holidays, emergencies or rescheduling;
- customers can’t look up or cancel their own booking;
- every reply takes a few seconds on consumer hardware;
- the anti-fabrication checks rely on text matching: useful, but not infallible.
Real-world use would need HTTPS, named users with multi-factor authentication, audit logs, persistent sessions, backups and encryption, rate limiting, monitoring, a GDPR assessment, integration with existing calendars such as Google or Outlook, and customer notifications.
What I’m taking away
With Eon I had already learned that an LLM’s output is untrusted input. Local Booking Agent pushed me one step further: validating what the model produces isn’t enough, you have to design the system so the model simply has no way to do damage.
Here the model is great at what it’s good at: understanding a badly written sentence, politely asking for a missing detail, replying naturally. Everything else (dates, slots, customer details, writing to the database) stays in code, which is boring, predictable and testable. And that’s exactly why you can trust it.