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.

Customer page with the box for describing the problem, three sample requests and the availability panel
The customer page: you describe the problem in your own words, with slots read from the calendar on the right

The project by the numbers

ItemValue
Backend (Python)13 files, about 1,880 lines
Frontend (HTML, CSS and JS, no framework)9 files, about 2,440 lines
Automated tests74 tests, about 800 lines, under 1 second, without Ollama
Python dependencies5: fastapi, uvicorn, httpx, pydantic, pytest
Frontend dependenciesnone: no CDN, no external fonts, 35 local SVG icons
Modelqwen3:8b via Ollama, about 5.2 GB
HardwareMac mini M4 with 16 GB of RAM
DatabaseSQLite 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 IMMEDIATE is 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:

ModuleLinesRole
calendar_service.py398working hours, free slots, draft validation, writing and verification
agent.py324prompt, model ↔ tools loop, deterministic paths, output filters
tools.py274the 3 tools exposed to the model, Pydantic schemas, anti-fabrication checks
main.py204thin routes, security headers, error handling
dates_it.py126“domani” (tomorrow), “giovedì” (Thursday), “il 25”, “25/10”, plus detection of vague periods
llm.py103HTTP client for Ollama, timeouts, response validation
db.py98schema, constraints, synthetic data
auth.py82from PIN to session token, attempt lockout

The key principle: who decides what

The model doesThe backend decides and verifies
Understands the request: the problem, the type of job, preferencesResolves relative dates using the Europe/Rome clock
Chooses which tool to callValidates every argument: types, formats, allowed values
Asks for missing details in its own wordsComputes free slots by querying the database
Replies briefly and politelyRejects 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:

  1. cerca_disponibilita(data, tipo_intervento, fascia) (“find availability”): reads the free slots.
  2. prepara_prenotazione(...) (“prepare booking”): creates an in-memory draft and writes nothing.
  3. 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.

Conversation with the calendar's free slots as buttons and the form for name, phone and address
Free slots from the calendar and the form for the customer's details

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

Draft summary with date, job, customer, phone, address and the Cancel and Confirm booking buttons
The draft: the slot is free and the details are complete, but nothing has been written 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.

Green Appointment booked card with the details and the booking code; in the right panel the 08:00 slot is now taken
The outcome after confirming: 1 check, 0.0 seconds, and the 08:00 slot disappears from availability

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:

RiskCountermeasure
Invents free slotsSlots only come from cerca_disponibilita; a time mentioned in the text that didn’t come from the calendar gets flagged
Gets “tomorrow” or “Thursday” wrongThe 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 mentionedThe draft’s date must have been written by the customer or offered by the calendar (data_non_indicata)
Invents name, phone or addressThey must appear in the customer’s messages (dati_non_forniti)
Says “booking confirmed” without saving anythingThe 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 argumentsPydantic schemas return a structured error and the model corrects itself
Malformed Ollama response or timeoutThe response is discarded, the customer sees a clear message and nothing is written
Two customers on the same slotBEGIN 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 system checks flag 333 333 3333 as a number to double-check and the assistant asks for a correct one
A number that doesn't look real is rejected, and the checks show why

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.

ModelChecks passedNotes
qwen3:8b18/18 (3 runs)reliable tool calling in Italian
qwen3:4b-instruct9/18faster, but offers slots without checking the calendar
qwen3:4b3/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 typeMedian
Confirmation (deterministic path)about 0.0 s
Turn with 1 model callabout 3 s
Turn with a calendar lookup (2 calls)8–12 s
Generation speedabout 16–19 tokens/s
Cold load and warm-up9–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=Strict cookie, scoped to /api;
Technician area login screen with the PIN field
The technician area holds customers' personal data and is protected by a PIN checked on the server
  • public APIs are kept to a minimum: only free/taken slots and model status;
  • no stack trace ever reaches the client, and /docs and /openapi.json are disabled;
  • security headers: a strict Content-Security-Policy, X-Frame-Options: DENY, nosniff, no-referrer and Cache-Control: no-store on 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.

Technician agenda with the next job, counters and the list of appointments for the next 14 days
The technician's agenda: the booking just made shows up right away, tagged Assistente (assistant)

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.