DEV Community

Cover image for Building a Conversational Booking Agent for Vehicle Rentals: MCP Endpoints, Dialog Passports, and Alternative Search
Dmitry Stepanov
Dmitry Stepanov

Posted on

Building a Conversational Booking Agent for Vehicle Rentals: MCP Endpoints, Dialog Passports, and Alternative Search

Building a Conversational Booking Agent for Vehicle Rentals: MCP Endpoints, Dialog Passports, and Alternative Search

When we built WorCo — a CRM for vehicle rental businesses — the AI booking agent was supposed to be a side feature. It became the core of the product.

This is the technical breakdown of how our agent actually works: how we designed the MCP-compatible API layer, how we solved the "AI agents forget things mid-conversation" problem using Redis-backed dialog passports, and what we learned from letting an LLM call real booking endpoints.


The problem with messaging-first rental businesses

Small rental operators in Southeast Asia — motorcycles in Phuket, scooters in Bali, boats in Koh Samui — run their entire sales funnel through WhatsApp and Telegram. A customer messages, asks about availability, negotiates dates and price, and either books or ghosts.

For the operator, every conversation is manual. For the customer, it's slow.

Our system connects directly to these messaging channels. The AI agent reads the incoming message, searches the fleet, calculates seasonal pricing, and creates a real booking record — without a human in the loop. When it works well, the booking appears in the operator's Kanban board within a few seconds of the customer typing "I want to rent a motorcycle next week."


Architecture: three distinct layers

Telegram / WhatsApp (inbound messages)
        │
        ▼
OpenAI Agents SDK (bot_image/openai_agents/)
  ├── Agent with @function_tool decorated tools
  ├── CRMClient — synchronous HTTP wrapper
  └── Redis-backed Dialog Passport
        │
        │  X-Bot-Token authenticated HTTP
        ▼
Django REST API (/api/mcp/ endpoints)
        │
        ├── Vehicle availability + conflict detection
        ├── Seasonal pricing calculation
        ├── Booking creation with idempotency
        └── Client CRM (auto-create + merge)
Enter fullscreen mode Exit fullscreen mode

The key architectural decision: the AI layer (OpenAI Agents SDK) and the business logic layer (Django) are completely separate containers. The agent calls HTTP endpoints the same way any external system would. There's no shared process, no direct ORM access from the agent side.


The MCP API layer

We chose not to run a formal MCP protocol server. Instead, we built REST endpoints that follow MCP's tool-call semantics — each endpoint has a single clear purpose, takes structured input, and returns structured output. Any agent that can make HTTP calls can use them.

All endpoints live under /api/mcp/ and require an X-Bot-Token header. This token identifies the bot and its associated company — all queries are automatically scoped to that company's data.

# modules/communications/views/mcp.py

def authenticate_bot(request) -> tuple[bool, Bot, str]:
    bot_token = request.headers.get('X-Bot-Token')
    if not bot_token:
        return False, None, "Missing X-Bot-Token header"

    try:
        bot = Bot.objects.get(token=bot_token, is_active=True)
        return True, bot, ""
    except Bot.DoesNotExist:
        return False, None, "Invalid or inactive bot token"
Enter fullscreen mode Exit fullscreen mode

Every view starts with authenticate_bot(). The company context flows automatically from the bot record — the agent never explicitly passes a company ID.

The tools we expose

GET  /api/mcp/vehicles/search_available/   # fleet search with filters
GET  /api/mcp/vehicles/availability/        # check a specific vehicle
GET  /api/mcp/vehicles/pricing/             # pricing tiers for a vehicle
GET  /api/mcp/vehicles/{uuid}/bookings/     # existing reservations
POST /api/mcp/vehicles/rentals/             # create a booking
GET  /api/mcp/company_info/                 # company details, currency
GET  /api/mcp/offices/                      # office locations
GET  /api/mcp/client/info/                  # client lookup by Telegram ID
POST /api/mcp/client/update/                # update client profile
GET  /api/mcp/health/                       # liveness check
Enter fullscreen mode Exit fullscreen mode

Vehicle search with tokenized matching

The search endpoint (/api/mcp/vehicles/search_available/) is the most-called tool in a typical booking conversation. We put significant effort into making it forgiving — customers don't say "Honda PCX 150", they say "small scooter" or "honda something automatic".

The brand/model filter uses AND-over-tokens logic: we split the input into tokens by non-alphanumeric characters, then require each token to match across multiple fields.

# modules/communications/views/mcp.py

import re as _re
tokens: list[str] = []
for part in [brand or "", model or ""]:
    if part:
        for t in _re.split(r"[^\w]+", part, flags=_re.IGNORECASE):
            t_norm = (t or "").strip().lower()
            if len(t_norm) >= 2:
                tokens.append(t_norm)

if tokens:
    for t in tokens:
        tok_filter = (
            Q(brand__id__icontains=t) |
            Q(brand__translations__name__icontains=t) |
            Q(model__slug__icontains=t) |
            Q(model__translations__name__icontains=t) |
            Q(custom_model_details__icontains=t) |
            Q(license_plate__icontains=t)
        )
        vehicles_qs = vehicles_qs.filter(tok_filter).distinct()
Enter fullscreen mode Exit fullscreen mode

"BMW GS 1200" becomes tokens ["bmw", "gs", "1200"]. Each token is ANDed — a vehicle must match all of them — but each token searches across brand slug, translated brand name, model slug, translated model name, custom details, and license plate.

Availability filtering uses strict interval overlap: a vehicle is excluded if there's any rental with status in ['pending', 'confirmed', 'active'] where rental.start_date < requested_end AND rental.end_date > requested_start.

conflicting = VehicleRental.objects.filter(
    vehicle__in=vehicles_qs,
    start_date__lt=end_dt,
    end_date__gt=start_dt,
    status__in=['pending', 'confirmed', 'active']
).values_list('vehicle__uuid', flat=True)

available = vehicles_qs.exclude(uuid__in=list(conflicting))
Enter fullscreen mode Exit fullscreen mode

Alternative period search

Here's a problem we didn't anticipate: an AI agent that just says "sorry, not available" is useless. We built a three-strategy alternative search that the agent calls automatically when the requested period is blocked.

# bot_image/openai_agents/tools.py

def _find_alternative_periods(
    vehicle_uuid: str,
    vehicle_info: Dict[str, Any],
    start_date: date,
    end_date: date,
    booked_ranges: List[Tuple[date, date]],
    max_results: int = 3
) -> List[Dict[str, Any]]:
    """
    Strategy 1: Period BEFORE first conflict
      - Keep the same start_date, end before the conflict begins
    Strategy 2: Period AFTER last conflict
      - Start from conflict end, maintain same duration
    Strategy 3: Gaps BETWEEN multiple bookings
      - Find free windows between existing reservations
    """
Enter fullscreen mode Exit fullscreen mode

The agent fetches existing bookings via /api/mcp/vehicles/{uuid}/bookings/, runs conflict detection locally, then presents alternatives with pre-calculated pricing. The customer sees something like: "June 10–17 is taken, but I can offer June 3–10 or June 20–27 at the same rate."

We never move both boundaries simultaneously — the alternative must be anchored either to the requested start date or to the post-conflict date, not floating freely.


The dialog passport problem

Multi-turn booking conversations have a fundamental state problem. The AI agent is stateless by design — each message is processed independently. But a booking conversation spans multiple turns:

Turn 1: "I want a motorcycle June 10-17"
  → agent searches, finds Honda CB500F, quotes price

Turn 2: "Yes, the Honda. My name is Alex"
  → agent needs to remember: which vehicle? which dates?

Turn 3: "+66 812 345 678"
  → agent needs: vehicle, dates, client name to create booking
Enter fullscreen mode Exit fullscreen mode

Without persistent state, the agent would need to re-call search endpoints on every turn, or rely on the LLM to extract context from conversation history — unreliable and expensive.

Our solution: a dialog passport stored in Redis, keyed by chat_id.

# bot_image/openai_agents/redis_store.py

def _passport_key(chat_id: str) -> str:
    return f"passport:{chat_id}"

def set_passport(chat_id: str, passport: Dict[str, Any], ttl_seconds: Optional[int] = None) -> bool:
    r = get_redis()
    key = _passport_key(chat_id)
    payload = json.dumps(passport, ensure_ascii=False)
    if ttl_seconds and ttl_seconds > 0:
        r.setex(key, ttl_seconds, payload)
    else:
        r.set(key, payload)
    return True

def get_passport(chat_id: str) -> Optional[Dict[str, Any]]:
    r = get_redis()
    data = r.get(_passport_key(chat_id))
    return json.loads(data) if data else None
Enter fullscreen mode Exit fullscreen mode

The passport has two sections:

{
  "client": {
    "first_name": "Alex",
    "contacts": [
      {"type": "phone", "value": "+66812345678"},
      {"type": "telegram_id", "value": "123456789"}
    ]
  },
  "rental_context": {
    "vehicle_uuid": "abc-123-...",
    "start_date": "2026-06-10",
    "end_date": "2026-06-17",
    "total_price": 280.0
  }
}
Enter fullscreen mode Exit fullscreen mode

The agent writes to the passport as it collects information. The create_vehicle_rental tool reads it when building the booking payload:

# bot_image/openai_agents/tools.py

def _collect_booking_context(conv_id, explicit_data):
    ctx_data = {**explicit_data}

    if conv_id:
        chat_id = _conv_id_to_chat_id(conv_id)
        passport = rs.get_passport(chat_id) or {}
        ctx_client = passport.get('client') or {}
        ctx_rental = passport.get('rental_context') or {}

        ctx_data['client'] = ctx_client
        ctx_data['rental'] = dict(ctx_rental)

    return ctx_data
Enter fullscreen mode Exit fullscreen mode

The passport has a configurable TTL — typically a few hours. If the conversation goes cold, the next message from that customer starts fresh.

We also store secondary data in Redis under the same chat_id prefix:

  • search:{chat_id} — last search results (so the agent can reference "the second option" without re-querying)
  • last_uuid:{chat_id} — UUID of the vehicle currently in discussion
  • status:{chat_id} — conversation phase (searching / quoting / confirming / booked)
  • history:{chat_id} — trimmed message history (max 50 items, FIFO)

Booking creation with idempotency

The POST /api/mcp/vehicles/rentals/ endpoint creates bookings. The critical concern: an LLM might call this endpoint twice for the same booking — once from a retry, once from confused reasoning about whether the booking succeeded.

We handle this with an explicit idempotency_key:

# modules/communications/views/mcp.py

if idempotency_key:
    existing = VehicleRental.objects.filter(
        company=company,
        vehicle=vehicle,
        client__first_name=client_first_name,
        start_date=start_date,
        end_date=end_date,
        extra_data__idempotency_key=idempotency_key
    ).first()
    if existing:
        return JsonResponse({
            'rental_uuid': str(existing.uuid),
            'order_number': existing.order_number,
            'status': existing.status,
            'idempotent': True  # signals this was a duplicate call
        })
Enter fullscreen mode Exit fullscreen mode

The idempotency_key is stored in extra_data (a JSONField on the rental model). The agent generates a key from the conversation context — typically f"{chat_id}:{vehicle_uuid}:{start_date}:{end_date}". If the booking already exists with that key, we return the existing record with idempotent: True so the agent knows it's a repeat.

Conflict detection runs before creation:

conflicting_rentals = VehicleRental.objects.filter(
    vehicle=vehicle,
    start_date__lt=end_dt,
    end_date__gt=start_dt,
    status__in=['pending', 'confirmed', 'active']
)

if conflicting_rentals.exists():
    return JsonResponse({
        'error': 'Vehicle unavailable for requested dates',
        'conflicting_rentals': [
            {'start_date': r.start_date.isoformat(), 'end_date': r.end_date.isoformat(), 'status': r.status}
            for r in conflicting_rentals
        ]
    }, status=400)
Enter fullscreen mode Exit fullscreen mode

The endpoint returns the conflicting rentals so the agent can suggest alternatives without an additional round-trip.


Client auto-create and merge

Customers messaging through Telegram don't have a CRM record yet. The booking endpoint auto-creates clients and handles the merge problem when a placeholder client gets real contact details later.

The flow:

  1. First message arrives from Telegram user 123456789 — a placeholder client is created with their Telegram ID as the only contact
  2. Customer provides their phone number — the passport is updated
  3. On booking creation, we look up existing clients by phone, then by Telegram ID
  4. If we find a placeholder (auto-created, no name), we merge it with the incoming data
  5. If we find a real client, we link the booking to them and update any missing contacts

This means a customer who messaged three months ago and gave their phone number is automatically recognized when they message again.


The OpenAI Agents SDK side

The agent tools are defined using @function_tool from the OpenAI Agents SDK. Each tool maps one-to-one with an MCP endpoint:

# bot_image/openai_agents/tools.py

from agents import function_tool as _function_tool

@tool
def search_available_vehicles(
    start_date: str,
    end_date: str,
    brand: Optional[str] = None,
    model: Optional[str] = None,
    budget: Optional[float] = None,
    office_id: Optional[str] = None,
) -> str:
    """
    Search for available vehicles in the fleet for given dates.
    Returns vehicles with pricing. If requested dates are unavailable,
    also returns alternative available periods.
    """
    client = _get_crm_client()
    response = client.search_available_vehicles(
        start_date=start_date,
        end_date=end_date,
        brand=brand,
        model=model,
        budget=budget,
        office_id=office_id,
    )
    # ... format and return
Enter fullscreen mode Exit fullscreen mode

The CRMClient is a plain requests-based synchronous HTTP client. It carries the X-Bot-Token header and optional Telegram context headers (X-User-Id, X-User-Username) that allow the Django side to log which Telegram user triggered each action.

Per-request client instances are injected via runtime_context — a module-level object that the agent handler sets at the start of each request. This avoids shared mutable state between concurrent conversations.


What we got wrong (and fixed)

Tool descriptions matter more than you think. Early versions of our search_available_vehicles tool had a vague description. The agent would call it with brand="motorcycle" (a vehicle type, not a brand) and get confused by empty results. Rewriting the description to explicitly distinguish vehicle type from brand — with examples — fixed this class of error without any code changes.

AI agents retry aggressively on ambiguity. When the availability check returned a 400 with conflict details, the agent sometimes called the endpoint again with slightly different dates, assuming the first call failed. Adding idempotent: True to successful duplicate calls and conflicting_rentals to conflict responses gave the agent enough signal to stop retrying.

State across turns requires explicit design. We initially relied on the LLM's context window to remember vehicle UUIDs and dates. It worked 80% of the time. The passport system pushed this to near-100% by making state explicit and retrievable rather than implicit and reconstructed.

Pricing must never be client-side. Our pricing model has tiered day-range rates plus seasonal rules with year-boundary wraparound (a Nov–Mar high season rule is not a simple date range). Any client-side calculation will be wrong for edge cases. The get_pricing tool always calls the server and returns a single pre-calculated number.


What's running in production

The full stack in Docker Compose:

  • Django 5 (backend + MCP endpoints)
  • OpenAI Agents SDK (bot container, one per multi-tenant deployment)
  • Redis (dialog passports, search cache, conversation history)
  • PostgreSQL 15 (all business data)
  • Telegram Bot API (inbound/outbound messages)

Orders created by the AI agent are tagged source='mcp' in the VehicleRental model — the same source-tagging pattern we use for admin-panel bookings, online widget bookings, and mobile app bookings. This makes it straightforward to track AI-attributed conversions in the operator's analytics.

The Kanban board shows all orders in real-time regardless of source. When the agent creates a booking, it appears on the board immediately and triggers a staff notification via Telegram — same as a manually created order.


The broader pattern

The approach here — REST endpoints that follow MCP semantics, a Redis-backed dialog state store, and tool-decorated functions in the agent SDK — is not specific to vehicle rentals. Any CRM or booking system with:

  • Multi-turn conversational intake (name, contact, dates, preferences)
  • External AI agents that need to read and write business data
  • Real-time availability that changes between turns
  • Idempotency requirements on write operations

...will hit the same design problems. The passport pattern and the MCP-compatible endpoint structure are our solutions. They're not the only solutions, but they've held up across thousands of real conversations.


If you're building something similar or want to talk about AI agents in operational business systems, find us at worco.io or leave a comment.


WorCo is an AI-powered CRM for vehicle rental businesses. Built on Django 5, React 18, TypeScript, PostgreSQL 15, and the OpenAI Agents SDK.

Top comments (0)