{"slug": "how-i-built-an-ai-ride-booking-agent-with-airtable", "title": "How I Built an AI Ride Booking Agent with Airtable", "summary": "A developer built Velora, an AI ride booking agent that converts natural language into live Airtable database operations and generates PDF receipts, deployed on Hugging Face Spaces. Velora uses Pydantic AI for agent orchestration, OpenAI's GPT-5-nano for natural language understanding, Pydantic for input/output validation, Airtable as the reservations database, ReportLab for receipt generation, Streamlit for the chat interface, HTTPX for async API calls, and Docker for reproducible deployment. The agent exposes three Pydantic-validated tools — fetch records, create reservations, and cancel reservations — letting users book or cancel a ride and receive a downloadable PDF receipt from a single sentence.", "body_md": "Most chatbots answer questions. This one books your ride, cancels it, and hands you a PDF receipt — all from a single sentence.\n\nI built **Velora**, an AI ride booking agent that turns natural language into real database operations. No forms. No dropdowns. Just type “Book a sedan from Downtown to Airport at 6 PM” and watch the agent create a live reservation in Airtable, then generate a downloadable PDF receipt.\n\nIn this article, I’ll walk you through exactly how it works, why I chose the stack I did, and how you can build something similar.\n\nThe gap between “AI demo” and “AI that actually does something” is massive. Most portfolio projects stop at generating text. Recruiters and clients want to see agents that interact with real systems — databases, APIs, and file generation.\n\nA ride booking agent is the perfect showcase because it covers the full lifecycle:\n\nIt proves you can build agents that don’t just talk — they *act*.\n\nVelora handles the complete ride booking workflow through natural language:\n\nThe agent is deployed live on Hugging Face Spaces and connected to a real Airtable base. Every operation you perform updates the database in real time.\n\n**Try it yourself:**\n\n```\n| Layer        | Technology          | Purpose                                      || ------------ | ------------------- | -------------------------------------------- || AI Framework | Pydantic AI         | Agent orchestration and tool calling         || LLM          | OpenAI GPT-5-nano   | Natural language understanding and reasoning || Validation   | Pydantic            | Structured input/output schemas              || Database     | Airtable            | Live NoSQL database for reservations         || PDF Engine   | ReportLab           | On-the-fly receipt generation                || UI           | Streamlit           | Interactive chat interface                   || HTTP Client  | HTTPX               | Async API calls to Airtable                  || Deployment   | Hugging Face Spaces | Free, public hosting with Docker             || Container    | Docker              | Reproducible deployment                      |\n```\n\nThis stack is intentionally lightweight. No heavy frameworks. No cloud bills. Just Python, a good agent library, and a live database.\n\nThe architecture is clean and linear:\n\n```\nUser (Natural Language)    ↓Streamlit Chat UI    ↓Pydantic AI Agent (GPT-5-nano)    ↓Tool Layer (Pydantic-validated)    ↓Airtable API (Live Database)    ↓PDF Receipt Generator\n```\n\nWhen a user types a message, the agent:\n\nPydantic AI is the backbone of this project. It handles agent initialization, system prompts, and tool registration with minimal boilerplate.\n\nHere’s how the agent is defined:\n\n``` python\nfrom pydantic_ai import Agent, RunContextfrom pydantic import BaseModel, Field\nagent = Agent(    \"openai:gpt-5-nano\",    system_prompt=(        \"You are a helpful assistant that can access Data table to \"        \"query the results, generate the receipts, create and cancel the reservations. \"        \"Use the available tools to answer questions about \"        \"Receipts data. Be concise and accurate in your responses.\"    ),)\n```\n\nThe system_prompt is critical. It tells the model exactly what it can do and keeps responses focused. Without this, the agent might hallucinate capabilities or give verbose, unhelpful answers.\n\n**Why Pydantic AI?**\n\nThe agent interacts with Airtable through three core tools: **fetch records, create reservations, and cancel reservations.** Each tool is a Pydantic-validated async function.\n\n```\nclass AirtableFetchInput(BaseModel):    reservation: int = Field(..., description=\"Primary key (Reservation number) to search\")    view: str = Field(\"Grid view\", description=\"Airtable view name\")\nphp\n@agent.toolasync def fetch_records(ctx: RunContext, args: AirtableFetchInput) -> list[dict]:    url = f\"https://api.airtable.com/v0/{BASE_ID}/{RECEIPT_TABLE_NAME}\"    headers = {        \"Authorization\": f\"Bearer {AIRTABLE_TOKEN}\",        \"Content-Type\": \"application/json\"    }    formula = f\"{{Reservation}}='{int(args.reservation)}'\"    params = {        \"filterByFormula\": formula,        \"maxRecords\": 5,        \"view\": args.view    }    async with httpx.AsyncClient(timeout=30) as client:        response = await client.get(url, headers=headers, params=params)        response.raise_for_status()    return response.json().get(\"records\", [])\n```\n\nNotice the filterByFormula approach. Instead of fetching all records and filtering in Python, we let Airtable do the work. This keeps API calls fast and costs low.\n\n```\nclass AirtableCreateReservationInput(BaseModel):    passenger_name: str = Field(..., description=\"Name of the passenger\")    car_type: str = Field(..., description=\"Type of car requested\")    pickup_time: str = Field(..., description=\"Pickup date and time\")    dropoff_time: str = Field(..., description=\"Drop-off date and time\")    contact_number: str = Field(..., description=\"Customer contact number\")    pickup_address: str = Field(..., description=\"Pickup location address\")    dropoff_address: str = Field(..., description=\"Drop-off location address\")\npython\n@agent.toolasync def create_reservation(ctx: RunContext, args: AirtableCreateReservationInput) -> dict:    url = f\"https://api.airtable.com/v0/{BASE_ID}/{RESERVATION_TABLE_NAME}\"    headers = {        \"Authorization\": f\"Bearer {AIRTABLE_TOKEN}\",        \"Content-Type\": \"application/json\"    }    record_data = {        \"records\": [{            \"fields\": {                \"Name\": args.passenger_name,                \"Car_Type\": args.car_type,                \"Pickup_Time\": args.pickup_time,                \"Dropoff_Time\": args.dropoff_time,                \"Contact_Number\": args.contact_number,                \"Pickup_Address\": args.pickup_address,                \"Dropoff_Address\": args.dropoff_address,                \"Reservation_Type\": \"New_Reservation\"            }        }]    }    async with httpx.AsyncClient(timeout=30) as client:        response = await client.post(url, headers=headers, json=record_data)        response.raise_for_status()    created_record = response.json().get(\"records\", [])[0]    return {        \"success\": True,        \"record_id\": created_record.get(\"id\"),        \"message\": f\"Reservation created successfully for {args.passenger_name}\"    }\n```\n\nThe Pydantic models act as a contract. If the user forgets to mention a pickup address, the agent will either ask for it or infer it — but the tool will never execute with invalid data.\n\nCancellation is a two-step process: find the record by reservation number, then patch its status field.\n\n```\nclass AirtableCancelReservationInput(BaseModel):    reservation_number: int = Field(..., description=\"Reservation number (primary key) to cancel\")\npython\n@agent.toolasync def cancel_reservation(ctx: RunContext, args: AirtableCancelReservationInput) -> dict:    # Step 1: Find the record    fetch_url = f\"https://api.airtable.com/v0/{BASE_ID}/{RESERVATION_TABLE_NAME}\"    headers = {        \"Authorization\": f\"Bearer {AIRTABLE_TOKEN}\",        \"Content-Type\": \"application/json\"    }    formula = f\"{{Reservation_Number}}={int(args.reservation_number)}\"    params = {\"filterByFormula\": formula, \"maxRecords\": 1}        async with httpx.AsyncClient(timeout=30) as client:        response = await client.get(fetch_url, headers=headers, params=params)        response.raise_for_status()        records = response.json().get(\"records\", [])                if not records:            return {\"success\": False, \"message\": f\"Reservation {args.reservation_number} not found\"}                record_id = records[0][\"id\"]                # Step 2: Update status        update_url = f\"{fetch_url}/{record_id}\"        update_data = {\"fields\": {\"Reservation_Type\": \"Cancelled_Reservation\"}}        update_response = await client.patch(update_url, headers=headers, json=update_data)        update_response.raise_for_status()                return {            \"success\": True,            \"reservation_number\": args.reservation_number,            \"message\": f\"Reservation {args.reservation_number} has been cancelled successfully\"        }\n```\n\nThis pattern — fetch then update — is common when working with Airtable. The reservation number is user-friendly, but Airtable’s API needs the internal record_id for updates.\n\nOnce a reservation is fetched, the agent triggers PDF generation using ReportLab. The receipt is built in-memory and served as a downloadable buffer.\n\n``` python\nfrom reportlab.lib.pagesizes import LETTERfrom reportlab.pdfgen import canvasfrom io import BytesIO\npython\ndef pdf_receipt_generator(data_input_api):    data = data_input_api.get(\"fields\", {})    pdf_buffer = BytesIO()    c = canvas.Canvas(pdf_buffer, pagesize=LETTER)    width, height = LETTER    y_position = height - 50    c.setFont(\"Helvetica-Bold\", 14)    c.drawString(50, y_position, \"TRIP RECEIPT\")    y_position -= 30    c.setFont(\"Helvetica\", 12)    for key, value in data.items():        text_line = f\"{key}: {value}\"        c.drawString(50, y_position, text_line)        y_position -= 20    c.save()    pdf_buffer.seek(0)    return pdf_buffer\n```\n\nThe PDF is generated server-side and streamed to the user as a download. No files are saved to disk, which keeps the deployment stateless and Hugging Face-friendly.\n\nThe UI is a simple chat interface built with Streamlit. It handles message history, agent calls, and PDF downloads.\n\n``` python\nimport streamlit as stfrom main import ask_agent_syncfrom pdf_generator import pdf_receipt_generator\nst.set_page_config(page_title=\"Intelligent Booking Agent\", page_icon=\":robot:\")st.title(\"Velora AI Agent\")st.write(\"Chat with Velora about Bookings and Receipts\")if \"messages\" not in st.session_state:    st.session_state.messages = []if \"history\" not in st.session_state:    st.session_state.history = Noneif \"tool_data\" not in st.session_state:    st.session_state.tool_data = []if \"pdfs\" not in st.session_state:    st.session_state.pdfs = []# Display chat historyfor msg in st.session_state.messages:    with st.chat_message(msg[\"role\"]):        st.markdown(msg[\"content\"])# User inputif prompt := st.chat_input(\"Type your message...\"):    st.session_state.messages.append({\"role\": \"user\", \"content\": prompt})        with st.chat_message(\"assistant\"):        message_placeholder = st.empty()        message_placeholder.markdown(\"Typing...\")                response = ask_agent_sync(prompt, st.session_state.history)        st.session_state.history = response[\"history\"]        st.session_state.messages.append({\"role\": \"assistant\", \"content\": response[\"output\"]})        message_placeholder.markdown(response[\"output\"])        # Extract tool data for PDF generation    for entry in response[\"history\"]:        if entry.__class__.__name__ == \"ModelRequest\":            for parts in entry.parts:                if parts.__class__.__name__ == \"ToolReturnPart\":                    if parts.tool_name == \"fetch_records\":                        st.session_state.tool_data.extend(parts.content)        if st.session_state.tool_data and not st.session_state.pdfs:        for item in st.session_state.tool_data:            pdf_buffer = pdf_receipt_generator(item)            st.session_state.pdfs.append(pdf_buffer)# Render download buttonsif st.session_state.pdfs:    st.markdown(\"### 📄 Available Receipts\")    for idx, item in enumerate(st.session_state.pdfs, start=1):        st.download_button(            label=f\"📄 Download Receipt {idx}\",            data=item,            file_name=f\"trip_receipt_{idx}.pdf\",            mime=\"application/pdf\",            key=f\"download_{idx}\"        )\n```\n\nThe key here is ask_agent_sync, a wrapper that bridges Streamlit's synchronous environment with the agent's async internals using nest_asyncio.\n\n``` python\nimport asyncioimport nest_asynciofrom agent import agentnest_asyncio.apply()\npython\nasync def ask_agent(prompt: str, history=None):    result = await agent.run(prompt, message_history=history)    return {\"output\": result.output, \"history\": result.all_messages()}def ask_agent_sync(prompt: str, history=None):    loop = asyncio.get_event_loop()    return loop.run_until_complete(ask_agent(prompt, history))\n```\n\nWithout nest_asyncio, running async code inside Streamlit would throw event loop conflicts. This small utility makes everything work smoothly.\n\nThe project is containerized with Docker and deployed on Hugging Face Spaces.\n\n```\nFROM python:3.13.5-slimWORKDIR /appRUN apt-get update && apt-get install -y \\    build-essential \\    curl \\    git \\    && rm -rf /var/lib/apt/lists/*COPY requirements.txt ./COPY src/ ./src/RUN pip3 install -r requirements.txtEXPOSE 8501HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/healthENTRYPOINT [\"streamlit\", \"run\", \"src/app.py\", \"--server.port=8501\", \"--server.address=0.0.0.0\"]\n```\n\n**Why Hugging Face Spaces?**\n\nAll sensitive credentials (Airtable token, base ID) are stored as Space Secrets. Nothing is hardcoded.\n\n1. Skipping Input Validation\n\nWithout Pydantic schemas, your agent might pass malformed data to your database. Always validate tool inputs before execution.\n\n2. Hardcoding API Keys\n\nNever commit tokens to GitHub. Use environment variables or platform secrets. This project uses .env locally and Hugging Face Space Secrets in production.\n\n3. Ignoring Async Patterns\n\nBlocking API calls freeze your UI. This project uses httpx.AsyncClient for all Airtable operations and nest_asyncio to bridge Streamlit's sync runtime.\n\n4. Over-Engineering the Stack\n\nYou don’t need LangChain, vector databases, or complex orchestration for every agent. Pydantic AI + a direct API client is often enough.\n\n5. Forgetting Error Handling\n\nAlways handle “record not found” gracefully. The cancellation tool explicitly checks if a reservation exists before attempting an update.\n\nPydantic AI is a Python framework for building type-safe AI agents. It uses Pydantic models to validate tool inputs and outputs, making agent behavior predictable and robust.\n\nYes. Pydantic AI supports multiple providers. You can swap openai:gpt-5-nano for anthropic:claude-3-sonnet, google:gemini-2.0, or any supported model with a one-line change.\n\nYes. The demo connects to a real Airtable base. You can view the live tables [here](https://airtable.com/appCvsvzsT4gE3di7/shrRxXovvVtIV3vXn) and see your changes reflected instantly.\n\nReportLab builds the PDF in-memory using a BytesIO buffer. The file is never saved to disk. Streamlit serves it directly as a download button.\n\nAbsolutely. Clone the repo, add your Airtable credentials to .env, install dependencies with pip install -r requirements.txt, and run streamlit run app.py.\n\nPlanned features include email receipt delivery, SMS confirmations, multi-database support, an admin dashboard, and voice-based booking.\n\nThis project proves that AI agents don’t need to be complex to be impressive. With under 300 lines of core logic, Velora creates reservations, queries a live database, cancels bookings, and generates PDF receipts — all from natural language.\n\nIf you’re building your AI portfolio, focus on **real integrations.** A chatbot that talks is nice. An agent that *does* something is memorable.\n\n**Check out the live demo, explore the database, and star the repo if you find it useful.**\n\nIf you enjoyed this breakdown, give it a clap and follow for more hands-on AI engineering content. Got questions? Drop them in the comments — I read every one.\n\n[How I Built an AI Ride Booking Agent with Airtable](https://pub.towardsai.net/how-i-built-an-ai-ride-booking-agent-with-airtable-f4190df713df) was originally published in [Towards AI](https://pub.towardsai.net) on Medium, where people are continuing the conversation by highlighting and responding to this story.", "url": "https://wpnews.pro/news/how-i-built-an-ai-ride-booking-agent-with-airtable", "canonical_source": "https://pub.towardsai.net/how-i-built-an-ai-ride-booking-agent-with-airtable-f4190df713df?source=rss----98111c9905da---4", "published_at": "2026-09-22 04:40:47+00:00", "updated_at": "2026-09-22 04:53:32.690607+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "ai-products", "developer-tools"], "entities": ["Velora", "Airtable", "Pydantic AI", "OpenAI", "GPT-5-nano", "ReportLab", "Streamlit", "Hugging Face Spaces"], "alternates": {"html": "https://wpnews.pro/news/how-i-built-an-ai-ride-booking-agent-with-airtable", "markdown": "https://wpnews.pro/news/how-i-built-an-ai-ride-booking-agent-with-airtable.md", "text": "https://wpnews.pro/news/how-i-built-an-ai-ride-booking-agent-with-airtable.txt", "jsonld": "https://wpnews.pro/news/how-i-built-an-ai-ride-booking-agent-with-airtable.jsonld"}}