The problem
Scheduling a bar by hand is a monthly evening lost. Availability arrives by message, in every format imaginable. People have fixed days, others swap constantly. Some shifts need a specific role behind the counter. Events change the staffing need for a single evening. And it should still be fair — nobody wants every Saturday.
The result was a spreadsheet that only one person understood, and a plan that arrived late because rebuilding it after one dropout meant redoing the puzzle.
How it works
Everything runs on my own hardware: n8n in Docker on a Synology, Firestore as the data store, a Cloudflare Tunnel as the way in. There is no server I rent and no platform I depend on.
The parts worth talking about
The calendar feed does the last mile
A plan nobody reads is worthless. Each employee gets an ICS feed they subscribe to once; after that their shifts simply appear in Apple Calendar, Google Calendar or Outlook — and update themselves when the plan changes. The generator follows RFC 5545 properly: deterministic UIDs so an update replaces the existing entry instead of adding a second one, a VTIMEZONE block with daylight-saving rules, and correct date maths for shifts that run past midnight.
Authorisation never goes through the model
Admin functions sit behind a PIN. In an early version the agent decided whether the PIN was correct — and once accepted a wrong one. The fix was structural: the tool now only receives the raw attempt, a step in front of it fetches the stored PIN and compares it in code. The model never learns whether it matched.
The rule that came out of it: any tool whose result affects permissions must not take that permission as an argument from the model. Language models are good at intent, not at gatekeeping.
The model was the wrong one first
The first version ran on Gemini and kept losing fields in tool calls with several required arguments — and crashed on reading history when it called a tool without any preceding text. Switching to Claude Haiku fixed all of it in one step. The lesson was less about the model than about the diagnosis: I spent days working around symptoms that had one shared cause.
Serial beats parallel
Node references across parallel branches in n8n are fragile — a step can run before the one it reads from. The matching engine therefore runs strictly serially. It costs a few seconds and removes an entire class of intermittent failure that is miserable to reproduce.
Stack
Firestore is locked down to default-deny; the workflows reach it through a service account via IAM, so no browser client can read the data even if it knew the project.
Right now
This is not a screenshot. The dot below is a live check against the running system — the same endpoint the home page uses.
Checking status…
The demo on the home page is the real interface with invented staff — click around, nothing is saved.