Meridian Advisory Scheduling Agent
An AI receptionist that books, reschedules, and cancels consultations over Telegram, with the database, not the model, deciding what's actually allowed to happen.
The separate MCP tool server the agent calls into: check_availability, book_appointment, reschedule_appointment, cancel_appointment, record_appointment, update_appointment_record, and list_my_appointments, each backed by Google Calendar or Postgres directly.
Fully built and tested in a local n8n environment, including the MCP tool server, the confidence-gated booking flow, and the automatic rollback path. This is a working prototype demonstrating the architecture, not a production deployment. Before real customer use, the MCP endpoint needs to move off a local tunnel, and the availability logic needs load testing with concurrent bookings closer together than manual testing can produce.
Two workflows, connected over MCP. The Telegram Agent Workflow receives the message, builds a stable customer identity from the Telegram user ID, and claims message-level idempotency in Postgres before anything else happens, a duplicate delivery throws a unique-constraint violation and is dropped silently rather than processed twice. The message then goes to a Gemini-backed agent with access to seven tools exposed by the second workflow, the MCP Calendar Server.
Those seven tools cover check availability, book, reschedule, cancel, record, update record, and list appointments. The agent calls them in a fixed order the system prompt enforces: for a booking, Calendar acts first (creating the event), then Supabase is asked to record it, the real gate. Supabase's own constraints, a business-hours trigger, a duration bound, an exclusion constraint against overlapping time ranges, decide whether the booking is actually valid, regardless of what the agent believed when it called the tool. If Supabase rejects it, the agent deletes the calendar event it just created, so a rejected booking never leaves a live event behind.
A second unique constraint on (customer_id, start_time) handles a subtler case: a customer's own retried request. Without it, a timeout-and-resend would look identical to two different customers grabbing the same slot. With it, a same-customer retry returns zero rows and no error, and is confirmed normally, while a real cross-customer conflict still throws.
- Calendar acts first, Supabase decides, a deliberate ordering so the customer-facing artifact (the calendar invite) only ever exists once the underlying booking is something the database has actually accepted
- Business hours, duration bounds, and overlap prevention are enforced with real Postgres constraints and a trigger, not by asking the LLM to self-police, so the rule holds even if the agent's reasoning is wrong
- Message idempotency uses a Postgres unique constraint and onError continueErrorOutput rather than a separate lookup-then-insert, the duplicate case is the constraint violation itself, no extra query needed
- A customer's own retried booking request was being read as a real conflict and rejected, even though the original request had actually succeeded. Validated the fix directly with concurrent SQL inserts against Supabase, since model and network latency made it impossible to reliably force a true race through the Telegram interface itself
- A rejected Supabase write could leave a calendar event behind with no matching database record, since booking is two separate writes. Fixed by making the agent's fallback path an explicit rollback, delete the calendar event, rather than just reporting the failure and leaving the mismatch
- Field-shape mismatches between what the agent sent and what a tool expected caused hard failures with no useful message, an attendees field needed to be an array, not a string, and a duration field with no default could resolve to undefined and crash a parameterized query outright
- A thrown error from record_appointment (the real booking gate) is treated as a genuine rejection, the agent explains why and offers another time, then rolls back the calendar event it already created
- Zero rows with no error from record_appointment means a safe, same-customer retry, confirmed normally rather than treated as a failure
- Any other failure falls back to one honest, fixed message telling the customer the system is temporarily unavailable, rather than the agent improvising an explanation
- The Scheduling Agent node itself retries on failure (3 tries, 5 second wait) before falling back to the fixed unavailability message
- Telegram replies retry up to 5 times with a 2 second wait, since a dropped reply is worse than a slightly delayed one
- customer_id comes from the Telegram platform itself and is injected directly, the agent is explicitly instructed never to ask for it or accept one the customer offers
- Every mutating database query filters on customer_id in the WHERE clause, not just as a value being written, so one customer's request can never modify another customer's row
- The MCP tool server runs behind its own webhook path, separate from the customer-facing Telegram workflow
Treating 'the agent decided to call a tool' and 'the tool's effect actually happened' as two genuinely separate things. That separation is what makes the rollback logic possible, and it's the same discipline I'd want in place before letting any agent touch something transactional for a client.
Telling a genuine double-booking apart from a customer's own retried request, when both looked identical from the database's point of view until I added a second, more specific constraint to distinguish them.
- Move the MCP endpoint off a local tunnel onto a stable hosted URL
- Load test availability checking under real concurrent booking attempts
- Add a lightweight rate limit ahead of the agent
- Extend rollback logic to cover a Calendar failure that happens after a successful Supabase write, currently the rarer direction of the two
The Global Error Handler
Every workflow on this site, including this one, reports into the same error handler instead of failing silently. When any node in any workflow throws, n8n's Error Trigger catches it, a small code step pulls out the workflow name, the node that failed, and the actual error message, and it lands in my inbox as a plain email alert. One shared piece of infrastructure instead of duplicating error-handling logic four separate times.