How I Built an AI Ride Booking Agent with Airtable 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. Most chatbots answer questions. This one books your ride, cancels it, and hands you a PDF receipt — all from a single sentence. I 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. In 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. The 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. A ride booking agent is the perfect showcase because it covers the full lifecycle: It proves you can build agents that don’t just talk — they act . Velora handles the complete ride booking workflow through natural language: The 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. Try it yourself: | 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 | This stack is intentionally lightweight. No heavy frameworks. No cloud bills. Just Python, a good agent library, and a live database. The architecture is clean and linear: User Natural Language ↓Streamlit Chat UI ↓Pydantic AI Agent GPT-5-nano ↓Tool Layer Pydantic-validated ↓Airtable API Live Database ↓PDF Receipt Generator When a user types a message, the agent: Pydantic AI is the backbone of this project. It handles agent initialization, system prompts, and tool registration with minimal boilerplate. Here’s how the agent is defined: python from pydantic ai import Agent, RunContextfrom pydantic import BaseModel, Field agent = 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." , The 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. Why Pydantic AI? The agent interacts with Airtable through three core tools: fetch records, create reservations, and cancel reservations. Each tool is a Pydantic-validated async function. class AirtableFetchInput BaseModel : reservation: int = Field ..., description="Primary key Reservation number to search" view: str = Field "Grid view", description="Airtable view name" php @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", Notice 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. class 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" python @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}" } The 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. Cancellation is a two-step process: find the record by reservation number, then patch its status field. class AirtableCancelReservationInput BaseModel : reservation number: int = Field ..., description="Reservation number primary key to cancel" python @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" } This 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. Once a reservation is fetched, the agent triggers PDF generation using ReportLab. The receipt is built in-memory and served as a downloadable buffer. python from reportlab.lib.pagesizes import LETTERfrom reportlab.pdfgen import canvasfrom io import BytesIO python def 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 The 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. The UI is a simple chat interface built with Streamlit. It handles message history, agent calls, and PDF downloads. python import streamlit as stfrom main import ask agent syncfrom pdf generator import pdf receipt generator st.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}" The key here is ask agent sync, a wrapper that bridges Streamlit's synchronous environment with the agent's async internals using nest asyncio. python import asyncioimport nest asynciofrom agent import agentnest asyncio.apply python async 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 Without nest asyncio, running async code inside Streamlit would throw event loop conflicts. This small utility makes everything work smoothly. The project is containerized with Docker and deployed on Hugging Face Spaces. FROM 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" Why Hugging Face Spaces? All sensitive credentials Airtable token, base ID are stored as Space Secrets. Nothing is hardcoded. 1. Skipping Input Validation Without Pydantic schemas, your agent might pass malformed data to your database. Always validate tool inputs before execution. 2. Hardcoding API Keys Never commit tokens to GitHub. Use environment variables or platform secrets. This project uses .env locally and Hugging Face Space Secrets in production. 3. Ignoring Async Patterns Blocking API calls freeze your UI. This project uses httpx.AsyncClient for all Airtable operations and nest asyncio to bridge Streamlit's sync runtime. 4. Over-Engineering the Stack You don’t need LangChain, vector databases, or complex orchestration for every agent. Pydantic AI + a direct API client is often enough. 5. Forgetting Error Handling Always handle “record not found” gracefully. The cancellation tool explicitly checks if a reservation exists before attempting an update. Pydantic 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. Yes. 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. Yes. 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. ReportLab builds the PDF in-memory using a BytesIO buffer. The file is never saved to disk. Streamlit serves it directly as a download button. Absolutely. Clone the repo, add your Airtable credentials to .env, install dependencies with pip install -r requirements.txt, and run streamlit run app.py. Planned features include email receipt delivery, SMS confirmations, multi-database support, an admin dashboard, and voice-based booking. This 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. If you’re building your AI portfolio, focus on real integrations. A chatbot that talks is nice. An agent that does something is memorable. Check out the live demo, explore the database, and star the repo if you find it useful. If 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. 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.