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:
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.
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.
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.
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.
- Skipping Input Validation
Without Pydantic schemas, your agent might pass malformed data to your database. Always validate tool inputs before execution.
- 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.
- 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.
- 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.
- 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 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 was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.