May 20, 2026 · 12 min read

I Made My Website Bookable by Any AI Agent

MCP JSON-RPC exchange

Yesterday at Google I/O 2026, Google announced that AI agents can now book things, shop, and pay on your behalf. AP2, Universal Cart, Gemini Spark — the whole stack. The narrative is compelling: you tell your AI "book me an hour with Tanmay" and it just handles it. No forms. No tabs. No friction.

I wanted to be on the receiving end of that. So I spent the weekend making tanmaybohra.com callable by any AI agent. Here's what actually shipped, what's still theoretical, and the three bugs that nearly sank it on Saturday morning.

The mental model that matters

Most people read "AI booking" and imagine a chatbot on your site that talks to a calendar API. That's not what AP2 is.

The correct model: your website becomes a callable service for external AI agents. A user is talking to their AI — Gemini, Claude, whatever. They say "book me an hour with Tanmay." The AI looks up whether tanmaybohra.com exposes an MCP server, finds it via /.well-known/mcp/server-card.json, calls get_available_slots(), presents options, gets confirmation, and calls book_consultation(). The payment link lands in the conversation. The user pays. Done.

Your site never saw a human request. An AI called your API, held a slot, and returned a payment URL to the user's conversation. That's the shift.

The chatbot embedded on tanmaybohra.com still exists — it's the fallback for humans who don't have an AI agent. But it's not the primary surface anymore.

Two surfaces, one service layer

The site chatbot and the MCP server are two different entry points into the same underlying services — slots.py, mandate.py, booking.py. One path goes through Gemini tool-calling, the other through MCP JSON-RPC. The services don't care which.

Here's the full architecture:

SURFACE A — SITE CHATBOT (works today)UserFastAPIPOST /chatGemini 3.5 Flashtool loopRazorpayshort_url ←User payswebhook firesSURFACE B — MCP SERVER (for AI agents)AI AgentGemini/ClaudePOST /mcpJSON-RPC 2.0Same servicesslots · mandate · bookingRazorpayAP2 mandateUser toldby their AISHAREDWebhook: POST /booking/webhook/razorpay → slots.confirm() + mandate.close() + ntfy pushBackground: release_expired() every 60s · HMAC-SHA256 signature verification

Surface A — POST /chat. A user types "book me a slot." _is_booking_intent() detects it and routes to gemini.generate_booking(), which runs a tool loop. Gemini calls get_available_slots() to fetch the SQLite slots, presents them, waits for the user to pick one and provide name/phone, then calls create_payment_link(). The loop terminates when Gemini produces a text reply with the payment URL. This path exists today and runs for any human using the chatbot.

Surface B — POST /mcp. An AI agent sends JSON-RPC 2.0 requests. Same tool sequence, same services underneath. The difference: the AI agent might also carry an AP2 mandate — a signed credential that says "I'm authorized to spend up to ₹2,000 on consultations for this user." If present, we validate it before creating the payment link.

MCP discovery: how Gemini finds you

Before an AI agent can call your tools, it needs to discover them. The spec defines two well-known endpoints every MCP-capable site should serve:

GET /.well-known/mcp/server-card.json   (SEP-1649)
GET /.well-known/mcp                    (SEP-1960, endpoint manifest)

The server-card is the richer one — it describes your server, its tools, and transport details. AI agents check this first.

{
  "$schema": "https://modelcontextprotocol.io/schemas/server-card/v1.0",
  "protocolVersion": "2025-06-18",
  "serverInfo": {
    "name": "Tanmay Bohra — Consultation Booking",
    "homepage": "https://tanmaybohra.com"
  },
  "transport": {
    "type": "streamable-http",
    "url": "https://api.tanmaybohra.com/mcp"
  },
  "capabilities": {"tools": true},
  "tools": [
    {"name": "get_available_slots"},
    {"name": "book_consultation"}
  ]
}

One thing that trips people up: both well-known endpoints need Access-Control-Allow-Origin: * in the response headers — not your normal CORS policy. Browser-based AI clients need to read these without credentials. The FastAPI CORSMiddleware won't cover it (it's restricted to known origins). You need to set the headers directly on the JSONResponse.

AP2 mandate simulation

AP2 mandate lifecycle

AP2 is a payment credential protocol for AI agents. The idea: a user pre-authorizes their AI to spend up to some amount in some category. This creates a signed Intent Mandate stored in the user's AI wallet. When the AI books with a merchant, it presents the mandate. The merchant validates it and charges — no human-facing payment page needed for pre-authorized amounts.

The real AP2 uses ECDSA P-256 signatures and W3C Verifiable Credentials. Neither Razorpay's AP2 adapter nor the broader ecosystem are production-ready yet. So I simulated it with HMAC-SHA256, same JSON schema, with a designed upgrade path.

def create_intent(user_id: str, slot_id: str) -> dict:
    amount_inr = settings.consultation_amount_paise // 100
    mandate = {
        "mandate_id": f"mnd_{uuid.uuid4().hex[:10]}",
        "type": "intent",
        "version": "0.1-simulated",
        "user_id": user_id,
        "slot_id": slot_id,
        "max_inr": amount_inr,
        "max_transactions": 1,
        "valid_until": int(time.time()) + settings.mandate_expiry_secs,
        "category": "consultation",
    }
    mandate["proof"] = _sign(mandate)   # HMAC-SHA256 today
    # Phase 4: replace _sign() with ECDSA P-256 signing
    # Wrap in W3C VC envelope, update version to "1.0"
    # Everything else stays identical — schema was designed for this
    return mandate

The mandate is created silently inside the booking tool dispatch, before the Razorpay API call. validate() checks: exists in DB, status is active, not expired, amount within the hard cap (max_mandate_inr = 10000), HMAC proof matches. The hard cap is enforced in code, not in a prompt — a prompt can be tricked, a Python comparison cannot.

Tool descriptions are the orchestration

Andrej Karpathy's insight about LLMs: the context window is the lever. Everything that matters to the model has to be in the context. For tool-calling, that means the tool descriptions need to encode the sequencing rules, not a separate chain-of-thought prompt.

protos.FunctionDeclaration(
    name="create_payment_link",
    description=(
        "Creates a Razorpay payment link for a specific slot. "
        "Call ONLY after: (1) get_available_slots returned slots, "
        "(2) the user picked a slot, "
        "(3) you have the customer's name and phone number. "
        "If you don't have name or phone, ask for them before calling this. "
        "Returns {short_url, expires_minutes}. "
        "Include short_url verbatim in your reply — do not paraphrase it."
    ),
    ...
)

That last line — "include short_url verbatim in your reply" — is important. Without it, Gemini might paraphrase the URL or describe it instead of showing it, which breaks the user experience. The description IS the instruction. No system prompt chain needed.

Where the ecosystem actually stands

AP2 + MCP ECOSYSTEM READINESS — MAY 2026MCP Server100%Chat booking100%HMAC mandate sim90%Razorpay pay links80% *— NEEDS ECOSYSTEM OR CONFIG ———————————————————————Cal.com real slots40%Real AP2 mandate20%Gemini Spark native10% (US Alpha)Universal Cart (UX)5%* needs RAZORPAY_KEY_ID + RAZORPAY_KEY_SECRET in .env

The top half works today. You can clone this, add Razorpay keys, seed slots, and have a fully functional AI-bookable consultation service running on Fly.io in under an hour.

The bottom half needs the ecosystem to catch up. Cal.com's MCP server (mcp.cal.com) would replace the SQLite slot management with real calendar availability — get_availabilitycreate_booking after payment. The Google Calendar MCP (calendarmcp.googleapis.com) would auto-create a Google Meet link post-payment. Real AP2 mandate validation — ECDSA P-256, W3C VC envelope — is waiting on Razorpay's AP2 adapter, which isn't out yet. And Gemini Spark, the consumer-facing product where users say "book me a slot," is US Alpha only. This isn't a product gap. It's a timing gap.

Three bugs that cost a Saturday morning

timedelta missing in _dispatch_booking_tool. The function creates IST timezone-aware datetimes using tz(timedelta(hours=5, minutes=30)). timedelta was imported inside generate_booking() (the caller), not inside _dispatch_booking_tool itself. Python function scopes don't share imports. The booking tool returned a NameError and Gemini politely apologized to the user. Fix: add from datetime import timedelta inside the function.

%-I in strftime on Windows. Linux strftime supports %-I to strip the leading zero from 12-hour time (09:00 → 9:00). Windows does not. Since the server runs on Fly.io (Linux) in production this would never surface in CI, but it crashes during local development on Windows. Fix: use %I and .replace(" 0", " ") to strip the padded zero portably.

Seeding slots before tables exist. The seed script does sqlite3.connect(DB_PATH) directly, bypassing init_db(). If you run python scripts/seed_slots.py before the FastAPI app has started once (which creates the schema), every INSERT fails silently. Fix: call init_db() at the top of the seed script. CREATE TABLE IF NOT EXISTS is idempotent — safe to call twice.

What's next

Three upgrades waiting for the ecosystem:

Cal.com integration (Phase 2). Replace the SQLite slot table with a live call to GET https://api.cal.com/v2/slots?username=tanmay-bohra&eventTypeSlug=consultation. On webhook confirmation, POST /v2/bookings to create the confirmed booking. No more manual slot seeding.

Google Calendar MCP (Phase 2). After payment, call create_event on the Google Calendar MCP server to generate a Google Meet link and invite. Right now the webhook just sends an ntfy notification — the scheduling is still manual.

Real AP2 mandate validation (Phase 4). When Razorpay ships their AP2 adapter, replace the _sign() function in mandate.py with ECDSA P-256 signing, wrap in a W3C VC envelope, and update ap2_version from "0.1-simulated" to "1.0". The schema, the DB tables, the validation logic — nothing else changes. It was designed for this upgrade path from day one.

If you want to connect this to Claude Desktop right now, add it as an MCP server: https://api.tanmaybohra.com/mcp. Then ask: "What are Tanmay's available consultation slots?" It'll call get_available_slots, show you the times, and offer to book one.

The ecosystem gap is real. But the infrastructure is ready. The mandate schema is forward-compatible. When AP2 ships, we flip a function.

Related topics
AP2MCPAI Agents +2

T
Tanmay Bohra
Full Stack Engineer at Grant Thornton Bharat. Building high-concurrency systems in Go and TypeScript.
← portfolio chat with tanmay ↗