All articles

Full-Stack AI14 min read

Building a Conflict-Safe AI Booking Platform with Gemini and PostgreSQL

How we replaced a WhatsApp-based manual process with a production agentic system handling payments, calendar, flight tracking, and zero double-bookings

  • Gemini
  • PostgreSQL
  • A2A Protocol
  • MCP
  • Stripe
  • Express
  • Cloud Run

George, a UK private driver, was running his entire airport transfer business through WhatsApp messages. Every booking was a back-and-forth conversation: confirm the route, calculate the fare, check the diary, send a payment link, then repeat the entire process for the return leg. Double-bookings happened. Customers slipped through. The solution had to be conversational — George's clients expect to type naturally — but it also had to be production-grade: payments, calendar, flight tracking, and conflict-safe scheduling all wired together.

The Agentic Architecture

The core is a Gemini streaming agentic loop running on Node.js/Express with 11 registered tools. Each HTTP request to /api/chat creates or resumes a session and drives the loop for up to 5 iterations — enough for complex multi-step bookings while preventing runaway tool calls.

User message (SSE)
  ↓
Express /api/chat
  ↓
Gemini 3.5 Flash (gemini-3.5-flash, temp 0.4)
  ├── geocodeAddress()      → Google Maps Platform
  ├── calculateFare()       → in-memory fare cache (sync)
  ├── checkAvailability()   → PostgreSQL + Google Calendar
  ├── createBooking()       → pg_advisory_lock(778723)
  ├── createPaymentLink()   → Stripe Checkout Session
  ├── addToCalendar()       → Google Calendar API v3 (JWT)
  ├── sendConfirmation()    → Gmail SMTP / Nodemailer
  ├── checkFlightStatus()   → AeroDataBox via RapidAPI
  ├── checkWeather()        → Google Weather API
  ├── modifyBooking()       → advisory lock + calendar update
  └── cancelBooking()       → Stripe refund + calendar delete
  ↓
Streaming SSE back to browser

The Double-Booking Problem

The most critical requirement was eliminating double-bookings. The naive solution — check availability then insert — creates a race condition between two concurrent requests. PostgreSQL advisory locks solve this at the database level without any application-layer complexity.

typescript
1// Advisory lock: only one booking operation runs at a time
2// Lock ID 778723 is arbitrary but consistent across all instances
3async function createBookingWithLock(details: BookingDetails) {
4  const client = await pool.connect()
5  try {
6    await client.query('SELECT pg_advisory_lock(778723)')
7
8    // Now we're the only process that can create bookings
9    const conflict = await client.query(
10      `SELECT ref FROM bookings
11       WHERE date = $1 AND cancelled = false
12       AND (
13         (time >= $2 AND time < $3) OR
14         (time < $2 AND time::time + interval '3 hours' > $2::time)
15       )`,
16      [details.date, details.time, endTime]
17    )
18
19    if (conflict.rows.length > 0) {
20      throw new Error(`Slot unavailable — conflict with booking ${conflict.rows[0].ref}`)
21    }
22
23    const ref = `GT-${crypto.randomBytes(4).toString('hex').toUpperCase()}`
24    await client.query(INSERT_BOOKING_SQL, [...details, ref])
25    return ref
26
27  } finally {
28    await client.query('SELECT pg_advisory_unlock(778723)')
29    client.release()
30  }
31}

Advisory locks are session-scoped in PostgreSQL. Using the same lock ID (778723) across all Cloud Run instances and the A2A agent ensures no concurrent booking can slip through, even under load. The lock is released automatically if the connection drops.

Fare Calculation Without Async Latency

Inside a Gemini tool call, every millisecond of latency adds up across a multi-iteration agentic loop. Fare calculation needed to be synchronous. The solution: load the fares config from PostgreSQL once at startup, cache it in memory, and recalculate synchronously. Any admin update to fares immediately flushes the cache.

typescript
1// Synchronous fare calculation — no async, no database round-trip
2function calculateFare(pickup: string, dropoff: string): number {
3  const iata = dropoff.match(/\([A-Z]{3}\)/)?.[1]
4  if (!iata) throw new Error('No IATA code found in dropoff address')
5
6  const base = faresCache.baseFares[iata] ?? 0
7  const pickupLower = pickup.toLowerCase()
8
9  // Zone adjustments sorted longest-first to avoid "kirby" matching before "west kirby"
10  const adjustment = faresCache.zoneAdjustments
11    .sort((a, b) => b[0].length - a[0].length)
12    .find(([zone]) => pickupLower.includes(zone))?.[1] ?? 0
13
14  return base + adjustment
15}

The thoughtSignature Problem

Gemini's streaming API includes thinking parts in the response — internal reasoning that must never be replayed into the conversation history. Replaying thought parts causes a 400 error from the API. The fix is to strip all parts with a truthy thoughtSignature before saving to the session.

typescript
1// Strip thought parts before persisting to chat_sessions
2function sanitizeHistory(contents: Content[]): Content[] {
3  return contents.map(c => ({
4    ...c,
5    parts: c.parts.filter(p => !('thoughtSignature' in p && p.thoughtSignature))
6  }))
7}
8
9// In the agentic loop:
10for (let i = 0; i < 5; i++) {
11  const stream = await ai.models.generateContentStream({
12    model: 'gemini-3.5-flash',
13    contents,  // already sanitized history
14    tools: TOOLS,
15    systemInstruction: buildSystemPrompt(),
16  })
17
18  let assistantParts: Part[] = []
19  for await (const chunk of stream) {
20    // Stream text to client via SSE
21    const text = chunk.candidates?.[0]?.content?.parts
22      ?.filter(p => p.text && !p.thoughtSignature)
23      ?.map(p => p.text).join('') ?? ''
24    if (text) res.write(`data: ${JSON.stringify({ text })}\n\n`)
25    assistantParts.push(...(chunk.candidates?.[0]?.content?.parts ?? []))
26  }
27
28  // Add to history (with sanitization)
29  contents.push({ role: 'model', parts: assistantParts })
30
31  const toolCalls = assistantParts.filter(p => p.functionCall)
32  if (toolCalls.length === 0) break  // No more tool calls — done
33
34  // Execute tools and add results
35  const toolResults = await Promise.all(toolCalls.map(executeToolCall))
36  contents.push({ role: 'user', parts: toolResults })
37}

A2A Protocol and MCP Interoperability

The platform exposes Google's Agent-to-Agent (A2A) protocol, making it callable from any other AI agent. A companion MCP server exposes the same booking tools to any MCP-compatible assistant. This means a travel planning AI, a personal assistant, or any future agent can book a George Travel transfer without any custom integration work.

json
1// /.well-known/agent.json — A2A agent card
2{
3  "name": "George Travel Booking Agent",
4  "description": "Books airport transfers for UK private driver service",
5  "url": "https://georgetravel.co.uk/a2a",
6  "version": "1.0",
7  "capabilities": {
8    "streaming": true,
9    "pushNotifications": false
10  },
11  "skills": [
12    {
13      "id": "book_transfer",
14      "name": "Book Airport Transfer",
15      "description": "Book a private driver transfer to/from UK airports",
16      "inputModes": ["text"],
17      "outputModes": ["text"]
18    }
19  ]
20}

Production Stack

LayerTechnologyDetail
AIGemini 3.5 Flash@google/genai@1.29, temp 0.4
BackendNode.js + ExpressTypeScript, tsx runtime
DatabasePostgreSQL 16Cloud SQL, unix socket
PaymentsStripestripe@22, API 2026-03-25.dahlia
CalendarGoogle Calendar API v3Service account JWT
EmailGmail SMTP + NodemailerApp Password auth
DeployDocker + Cloud Runeurope-west2, min 1 instance

Results

Double-bookings since launch
0
AI tools in the agentic loop
11
Integration protocols (REST + A2A + MCP)
3
Average booking completion time
<5s

The platform is live at georgetravel.co.uk. George went from managing every booking manually over WhatsApp to a system that handles the entire flow autonomously — including edge cases like flight delays triggering automatic calendar adjustments and proactive passenger notifications.

Request a discovery call

30 minutesGoogle MeetFree