Building a Conversational Booking Agent for Vehicle Rentals: MCP Endpoints, Dialog Passports, and Alternative Search 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. 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 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. php 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" 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 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. python 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 "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 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. python 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 """ 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 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 . php 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 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 } } The agent writes to the passport as it collects information. The create vehicle rental tool reads it when building the booking payload: python 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 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 } 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 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: - First message arrives from Telegram user 123456789 — a placeholder client is created with their Telegram ID as the only contact - Customer provides their phone number — the passport is updated - On booking creation, we look up existing clients by phone, then by Telegram ID - If we find a placeholder auto-created, no name , we merge it with the incoming data - 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: python 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 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 https://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.