{"slug": "building-a-conversational-booking-agent-for-vehicle-rentals-mcp-endpoints-dialog", "title": "Building a Conversational Booking Agent for Vehicle Rentals: MCP Endpoints, Dialog Passports, and Alternative Search", "summary": "Technical architecture of WorCo's AI booking agent for vehicle rentals, which uses a separate Django REST API layer with MCP-compatible endpoints and Redis-backed \"dialog passports\" to maintain conversation context. The system connects to messaging platforms like WhatsApp and Telegram, allowing an LLM to handle the entire booking process—from searching for vehicles using forgiving token-matching logic to creating reservations—without human intervention. A key design choice was keeping the AI layer (OpenAI Agents SDK) and business logic (Django) in separate containers, with the agent making authenticated HTTP calls to endpoints like `/api/mcp/vehicles/search_available/` rather than using a formal MCP protocol server.", "body_md": "# Building a Conversational Booking Agent for Vehicle Rentals: MCP Endpoints, Dialog Passports, and Alternative Search\n\nWhen 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.\n\nThis 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.\n\n## The problem with messaging-first rental businesses\n\nSmall 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.\n\nFor the operator, every conversation is manual. For the customer, it's slow.\n\nOur 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.\"\n\n## Architecture: three distinct layers\n\n```\nTelegram / WhatsApp (inbound messages)\n        │\n        ▼\nOpenAI Agents SDK (bot_image/openai_agents/)\n  ├── Agent with @function_tool decorated tools\n  ├── CRMClient — synchronous HTTP wrapper\n  └── Redis-backed Dialog Passport\n        │\n        │  X-Bot-Token authenticated HTTP\n        ▼\nDjango REST API (/api/mcp/ endpoints)\n        │\n        ├── Vehicle availability + conflict detection\n        ├── Seasonal pricing calculation\n        ├── Booking creation with idempotency\n        └── Client CRM (auto-create + merge)\n```\n\nThe 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.\n\n## The MCP API layer\n\nWe 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.\n\nAll endpoints live under `/api/mcp/`\n\nand require an `X-Bot-Token`\n\nheader. This token identifies the bot and its associated company — all queries are automatically scoped to that company's data.\n\n``` php\n# modules/communications/views/mcp.py\n\ndef authenticate_bot(request) -> tuple[bool, Bot, str]:\n    bot_token = request.headers.get('X-Bot-Token')\n    if not bot_token:\n        return False, None, \"Missing X-Bot-Token header\"\n\n    try:\n        bot = Bot.objects.get(token=bot_token, is_active=True)\n        return True, bot, \"\"\n    except Bot.DoesNotExist:\n        return False, None, \"Invalid or inactive bot token\"\n```\n\nEvery view starts with `authenticate_bot()`\n\n. The company context flows automatically from the bot record — the agent never explicitly passes a company ID.\n\n### The tools we expose\n\n```\nGET  /api/mcp/vehicles/search_available/   # fleet search with filters\nGET  /api/mcp/vehicles/availability/        # check a specific vehicle\nGET  /api/mcp/vehicles/pricing/             # pricing tiers for a vehicle\nGET  /api/mcp/vehicles/{uuid}/bookings/     # existing reservations\nPOST /api/mcp/vehicles/rentals/             # create a booking\nGET  /api/mcp/company_info/                 # company details, currency\nGET  /api/mcp/offices/                      # office locations\nGET  /api/mcp/client/info/                  # client lookup by Telegram ID\nPOST /api/mcp/client/update/                # update client profile\nGET  /api/mcp/health/                       # liveness check\n```\n\n## Vehicle search with tokenized matching\n\nThe search endpoint (`/api/mcp/vehicles/search_available/`\n\n) 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\".\n\nThe 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.\n\n``` python\n# modules/communications/views/mcp.py\n\nimport re as _re\ntokens: list[str] = []\nfor part in [brand or \"\", model or \"\"]:\n    if part:\n        for t in _re.split(r\"[^\\w]+\", part, flags=_re.IGNORECASE):\n            t_norm = (t or \"\").strip().lower()\n            if len(t_norm) >= 2:\n                tokens.append(t_norm)\n\nif tokens:\n    for t in tokens:\n        tok_filter = (\n            Q(brand__id__icontains=t) |\n            Q(brand__translations__name__icontains=t) |\n            Q(model__slug__icontains=t) |\n            Q(model__translations__name__icontains=t) |\n            Q(custom_model_details__icontains=t) |\n            Q(license_plate__icontains=t)\n        )\n        vehicles_qs = vehicles_qs.filter(tok_filter).distinct()\n```\n\n\"BMW GS 1200\" becomes tokens `[\"bmw\", \"gs\", \"1200\"]`\n\n. 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.\n\nAvailability filtering uses strict interval overlap: a vehicle is excluded if there's any rental with `status in ['pending', 'confirmed', 'active']`\n\nwhere `rental.start_date < requested_end`\n\nAND `rental.end_date > requested_start`\n\n.\n\n```\nconflicting = VehicleRental.objects.filter(\n    vehicle__in=vehicles_qs,\n    start_date__lt=end_dt,\n    end_date__gt=start_dt,\n    status__in=['pending', 'confirmed', 'active']\n).values_list('vehicle__uuid', flat=True)\n\navailable = vehicles_qs.exclude(uuid__in=list(conflicting))\n```\n\n## Alternative period search\n\nHere'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.\n\n``` python\n# bot_image/openai_agents/tools.py\n\ndef _find_alternative_periods(\n    vehicle_uuid: str,\n    vehicle_info: Dict[str, Any],\n    start_date: date,\n    end_date: date,\n    booked_ranges: List[Tuple[date, date]],\n    max_results: int = 3\n) -> List[Dict[str, Any]]:\n    \"\"\"\n    Strategy 1: Period BEFORE first conflict\n      - Keep the same start_date, end before the conflict begins\n    Strategy 2: Period AFTER last conflict\n      - Start from conflict end, maintain same duration\n    Strategy 3: Gaps BETWEEN multiple bookings\n      - Find free windows between existing reservations\n    \"\"\"\n```\n\nThe agent fetches existing bookings via `/api/mcp/vehicles/{uuid}/bookings/`\n\n, 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.\"\n\nWe 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.\n\n## The dialog passport problem\n\nMulti-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:\n\n```\nTurn 1: \"I want a motorcycle June 10-17\"\n  → agent searches, finds Honda CB500F, quotes price\n\nTurn 2: \"Yes, the Honda. My name is Alex\"\n  → agent needs to remember: which vehicle? which dates?\n\nTurn 3: \"+66 812 345 678\"\n  → agent needs: vehicle, dates, client name to create booking\n```\n\nWithout 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.\n\nOur solution: a **dialog passport** stored in Redis, keyed by `chat_id`\n\n.\n\n``` php\n# bot_image/openai_agents/redis_store.py\n\ndef _passport_key(chat_id: str) -> str:\n    return f\"passport:{chat_id}\"\n\ndef set_passport(chat_id: str, passport: Dict[str, Any], ttl_seconds: Optional[int] = None) -> bool:\n    r = get_redis()\n    key = _passport_key(chat_id)\n    payload = json.dumps(passport, ensure_ascii=False)\n    if ttl_seconds and ttl_seconds > 0:\n        r.setex(key, ttl_seconds, payload)\n    else:\n        r.set(key, payload)\n    return True\n\ndef get_passport(chat_id: str) -> Optional[Dict[str, Any]]:\n    r = get_redis()\n    data = r.get(_passport_key(chat_id))\n    return json.loads(data) if data else None\n```\n\nThe passport has two sections:\n\n```\n{\n  \"client\": {\n    \"first_name\": \"Alex\",\n    \"contacts\": [\n      {\"type\": \"phone\", \"value\": \"+66812345678\"},\n      {\"type\": \"telegram_id\", \"value\": \"123456789\"}\n    ]\n  },\n  \"rental_context\": {\n    \"vehicle_uuid\": \"abc-123-...\",\n    \"start_date\": \"2026-06-10\",\n    \"end_date\": \"2026-06-17\",\n    \"total_price\": 280.0\n  }\n}\n```\n\nThe agent writes to the passport as it collects information. The `create_vehicle_rental`\n\ntool reads it when building the booking payload:\n\n``` python\n# bot_image/openai_agents/tools.py\n\ndef _collect_booking_context(conv_id, explicit_data):\n    ctx_data = {**explicit_data}\n\n    if conv_id:\n        chat_id = _conv_id_to_chat_id(conv_id)\n        passport = rs.get_passport(chat_id) or {}\n        ctx_client = passport.get('client') or {}\n        ctx_rental = passport.get('rental_context') or {}\n\n        ctx_data['client'] = ctx_client\n        ctx_data['rental'] = dict(ctx_rental)\n\n    return ctx_data\n```\n\nThe passport has a configurable TTL — typically a few hours. If the conversation goes cold, the next message from that customer starts fresh.\n\nWe also store secondary data in Redis under the same `chat_id`\n\nprefix:\n\n-\n`search:{chat_id}`\n\n— last search results (so the agent can reference \"the second option\" without re-querying) -\n`last_uuid:{chat_id}`\n\n— UUID of the vehicle currently in discussion -\n`status:{chat_id}`\n\n— conversation phase (searching / quoting / confirming / booked) -\n`history:{chat_id}`\n\n— trimmed message history (max 50 items, FIFO)\n\n## Booking creation with idempotency\n\nThe `POST /api/mcp/vehicles/rentals/`\n\nendpoint 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.\n\nWe handle this with an explicit `idempotency_key`\n\n:\n\n```\n# modules/communications/views/mcp.py\n\nif idempotency_key:\n    existing = VehicleRental.objects.filter(\n        company=company,\n        vehicle=vehicle,\n        client__first_name=client_first_name,\n        start_date=start_date,\n        end_date=end_date,\n        extra_data__idempotency_key=idempotency_key\n    ).first()\n    if existing:\n        return JsonResponse({\n            'rental_uuid': str(existing.uuid),\n            'order_number': existing.order_number,\n            'status': existing.status,\n            'idempotent': True  # signals this was a duplicate call\n        })\n```\n\nThe `idempotency_key`\n\nis stored in `extra_data`\n\n(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}\"`\n\n. If the booking already exists with that key, we return the existing record with `idempotent: True`\n\nso the agent knows it's a repeat.\n\nConflict detection runs before creation:\n\n```\nconflicting_rentals = VehicleRental.objects.filter(\n    vehicle=vehicle,\n    start_date__lt=end_dt,\n    end_date__gt=start_dt,\n    status__in=['pending', 'confirmed', 'active']\n)\n\nif conflicting_rentals.exists():\n    return JsonResponse({\n        'error': 'Vehicle unavailable for requested dates',\n        'conflicting_rentals': [\n            {'start_date': r.start_date.isoformat(), 'end_date': r.end_date.isoformat(), 'status': r.status}\n            for r in conflicting_rentals\n        ]\n    }, status=400)\n```\n\nThe endpoint returns the conflicting rentals so the agent can suggest alternatives without an additional round-trip.\n\n## Client auto-create and merge\n\nCustomers 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.\n\nThe flow:\n\n- First message arrives from Telegram user\n`123456789`\n\n— a placeholder client is created with their Telegram ID as the only contact - Customer provides their phone number — the passport is updated\n- On booking creation, we look up existing clients by phone, then by Telegram ID\n- If we find a placeholder (auto-created, no name), we merge it with the incoming data\n- If we find a real client, we link the booking to them and update any missing contacts\n\nThis means a customer who messaged three months ago and gave their phone number is automatically recognized when they message again.\n\n## The OpenAI Agents SDK side\n\nThe agent tools are defined using `@function_tool`\n\nfrom the OpenAI Agents SDK. Each tool maps one-to-one with an MCP endpoint:\n\n``` python\n# bot_image/openai_agents/tools.py\n\nfrom agents import function_tool as _function_tool\n\n@tool\ndef search_available_vehicles(\n    start_date: str,\n    end_date: str,\n    brand: Optional[str] = None,\n    model: Optional[str] = None,\n    budget: Optional[float] = None,\n    office_id: Optional[str] = None,\n) -> str:\n    \"\"\"\n    Search for available vehicles in the fleet for given dates.\n    Returns vehicles with pricing. If requested dates are unavailable,\n    also returns alternative available periods.\n    \"\"\"\n    client = _get_crm_client()\n    response = client.search_available_vehicles(\n        start_date=start_date,\n        end_date=end_date,\n        brand=brand,\n        model=model,\n        budget=budget,\n        office_id=office_id,\n    )\n    # ... format and return\n```\n\nThe `CRMClient`\n\nis a plain `requests`\n\n-based synchronous HTTP client. It carries the `X-Bot-Token`\n\nheader and optional Telegram context headers (`X-User-Id`\n\n, `X-User-Username`\n\n) that allow the Django side to log which Telegram user triggered each action.\n\nPer-request client instances are injected via `runtime_context`\n\n— a module-level object that the agent handler sets at the start of each request. This avoids shared mutable state between concurrent conversations.\n\n## What we got wrong (and fixed)\n\n**Tool descriptions matter more than you think.** Early versions of our `search_available_vehicles`\n\ntool had a vague description. The agent would call it with `brand=\"motorcycle\"`\n\n(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.\n\n**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`\n\nto successful duplicate calls and `conflicting_rentals`\n\nto conflict responses gave the agent enough signal to stop retrying.\n\n**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.\n\n**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`\n\ntool always calls the server and returns a single pre-calculated number.\n\n## What's running in production\n\nThe full stack in Docker Compose:\n\n- Django 5 (backend + MCP endpoints)\n- OpenAI Agents SDK (bot container, one per multi-tenant deployment)\n- Redis (dialog passports, search cache, conversation history)\n- PostgreSQL 15 (all business data)\n- Telegram Bot API (inbound/outbound messages)\n\nOrders created by the AI agent are tagged `source='mcp'`\n\nin the `VehicleRental`\n\nmodel — 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.\n\nThe 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.\n\n## The broader pattern\n\nThe 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:\n\n- Multi-turn conversational intake (name, contact, dates, preferences)\n- External AI agents that need to read and write business data\n- Real-time availability that changes between turns\n- Idempotency requirements on write operations\n\n...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.\n\nIf you're building something similar or want to talk about AI agents in operational business systems, find us at [worco.io](https://worco.io) or leave a comment.\n\n*WorCo is an AI-powered CRM for vehicle rental businesses. Built on Django 5, React 18, TypeScript, PostgreSQL 15, and the OpenAI Agents SDK.*", "url": "https://wpnews.pro/news/building-a-conversational-booking-agent-for-vehicle-rentals-mcp-endpoints-dialog", "canonical_source": "https://dev.to/dimastepanov/building-a-conversational-booking-agent-for-vehicle-rentals-mcp-endpoints-dialog-passports-and-1oid", "published_at": "2026-05-20 13:16:08+00:00", "updated_at": "2026-05-20 13:34:32.338047+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "enterprise-software", "startups"], "entities": ["WorCo", "OpenAI Agents SDK", "Redis", "Django", "Southeast Asia", "Phuket", "Bali", "Koh Samui"], "alternates": {"html": "https://wpnews.pro/news/building-a-conversational-booking-agent-for-vehicle-rentals-mcp-endpoints-dialog", "markdown": "https://wpnews.pro/news/building-a-conversational-booking-agent-for-vehicle-rentals-mcp-endpoints-dialog.md", "text": "https://wpnews.pro/news/building-a-conversational-booking-agent-for-vehicle-rentals-mcp-endpoints-dialog.txt", "jsonld": "https://wpnews.pro/news/building-a-conversational-booking-agent-for-vehicle-rentals-mcp-endpoints-dialog.jsonld"}}