All posts

Teaching an AI Agent to Actually Book Things: Function-Calling in Production

Published Jun 11, 20245 min read
  • Engineering
  • Lueira
  • AI
Teaching an AI Agent to Actually Book Things: Function-Calling in Production

Key takeaways

  • Define a small, strictly-typed set of tools instead of letting the model free-form actions in text.
  • Every model-proposed booking is re-validated against the same atomic instructor+equipment+slot transaction that powers the web booking flow — the LLM proposes, the engine disposes.
  • Conversation state has to be rebuilt from persistent history keyed on the customer, so a reply three hours later still picks up where it left off, and availability is always re-checked against the live engine, never a cached snapshot.

In March I wrote about why we put an AI agent on WhatsApp for Lueira — the product bet that a mountain sports school shouldn't lose a booking just because it's 11pm on a Tuesday and nobody's at the desk. That post was about the why: the customer research, the channel choice, the trust question. This one is about the how, and it's the part that actually kept me up at night: getting a large language model to check real availability, create a real booking, and send a real payment link — without letting it anywhere near the atomic instructor-plus-equipment-plus-slot logic we built for the booking engine back in June 2023. The agent had to become a new client of that engine, not a new source of truth.

1. Giving the model a small set of tools, not free rein

The first decision, and the one that shapes everything downstream, is refusing to let the model generate actions in free text and then parse them. It's tempting — the model hands back something like "book Tuesday 10am with Marc for two people" and you write a regex to turn that into a database write. I've seen that pattern in enough demos to know exactly how it fails in production: ambiguous dates, invented instructor names, ids that don't exist, formats that drift every time you touch the prompt.

What we do instead is define a small, explicit set of functions the model is allowed to call — check_availability, create_booking, send_payment_link — each with a strict JSON schema. The model's only job is to understand what the customer wants in natural language and translate that into a well-typed call to one of these functions. It doesn't write SQL, it doesn't invent a slot id, and it doesn't decide on its own that a booking is confirmed. A simplified version of the create_booking schema looks like this:

{
  "name": "create_booking",
  "description": "Creates a booking for a specific slot, instructor and equipment set. Returns a booking id and payment link, or a conflict error if the slot is no longer available.",
  "parameters": {
    "type": "object",
    "properties": {
      "school_id": { "type": "string" },
      "activity_id": { "type": "string" },
      "slot_id": {
        "type": "string",
        "description": "Opaque id returned by check_availability. The model cannot invent this value."
      },
      "customer": {
        "type": "object",
        "properties": {
          "name": { "type": "string" },
          "phone": { "type": "string" },
          "email": { "type": "string" }
        },
        "required": ["name", "phone"]
      },
      "party_size": { "type": "integer", "minimum": 1 }
    },
    "required": ["school_id", "activity_id", "slot_id", "customer", "party_size"]
  }
}

Notice the slot_id field. It's an opaque identifier that only exists because check_availability returned it moments earlier — the model can't construct one out of thin air, and it can't silently turn "10am on Tuesday" into a slot without a real availability lookup in between. That single design choice removes an entire category of hallucinated bookings before we even get to validation.

2. The agent doesn't get to skip the line

Having a typed tool call doesn't mean we trust it. The model can be extremely confident and still be wrong — about which instructor is actually free, about whether the equipment set is still in stock, about whether someone else booked the same slot four seconds ago in a different WhatsApp thread. So every tool call the model proposes goes through exactly the same validation and transaction logic that a normal web booking goes through: the same atomic check on instructor, equipment and slot together that we described when we designed the booking engine. The LLM proposes; the engine disposes.

In code, that boundary looks roughly like this:

func handleCreateBooking(ctx context.Context, call ToolCall) (ToolResult, error) {
    var args CreateBookingArgs
    if err := json.Unmarshal(call.Arguments, &args); err != nil {
        return ToolResult{}, fmt.Errorf("invalid arguments from model: %w", err)
    }

    // The model's job ends here. From this point on, the same
    // atomic check-and-reserve transaction used by the web booking
    // flow is the only thing allowed to touch booking state.
    booking, err := bookingEngine.CreateBooking(ctx, CreateBookingInput{
        SlotID:    args.SlotID,
        Customer:  args.Customer,
        PartySize: args.PartySize,
    })
    if errors.Is(err, ErrSlotNoLongerAvailable) {
        return ToolResult{
            Content:   "That slot is no longer available.",
            Retryable: true,
        }, nil
    }
    if err != nil {
        return ToolResult{}, err
    }
    return ToolResult{
        BookingID:   booking.ID,
        PaymentLink: booking.PaymentLink,
    }, nil
}

The comment in that handler is the whole philosophy of the project: from the point where the model hands over a structured call, it's out of the loop. Nothing it says is trusted enough to touch booking state directly. If that feels like a lot of ceremony for what looks like a chatbot, it is — because the chatbot isn't the thing that matters. The booking is.

3. When the slot disappears mid-conversation

Because we re-validate everything, we also have to handle the case we were trying to prevent: the slot the model suggested five seconds ago is gone by the time create_booking actually runs. Someone else grabbed it through the app, or another WhatsApp conversation beat this one to the punch. In a naive integration this comes back as an error, and an error in a chat interface is a dead end — the customer just sees "something went wrong" and has to start over, usually by getting frustrated and calling the school directly, which defeats the entire point of building this.

So the tool result for create_booking isn't just success or failure — it carries enough structure for the agent to recover gracefully. When the booking engine returns a conflict, the tool response tells the model the slot is no longer available and hands back a fresh call to check_availability with the same parameters. The model's follow-up message to the customer is then generated from real, current data — "that 10am slot with Marc just got taken, but he has 11:30 and 3pm free today" — instead of a generic apology. The customer never sees the retry; they just see an agent that seems to know what's happening in real time. That behaviour isn't the model being clever. It's the direct result of feeding it a structured failure it can act on instead of a stack trace it can't.

4. Conversations that survive a three-hour gap

The other thing that breaks demo-quality agents in production is assuming a conversation is a single request-response cycle, the way a web session is. WhatsApp doesn't work like that. A customer can ask about availability at 9am, get distracted, and reply at noon from a different phone after switching devices. From the model's point of view, that has to look like a continuous conversation, even though nothing about the transport guarantees continuity.

In practice that means the state the agent needs — what the customer already asked, which slot was last discussed, whether a payment link is still pending — can't live in memory tied to a single message-handling process. It has to be reconstructed from a persistent conversation history keyed on the customer's WhatsApp identity, not on any particular session. Every time a message comes in, we rebuild enough context for the model to pick up exactly where things were left, including whatever the booking engine's state actually is right now, not whatever it was three hours ago. That last part matters more than it sounds: if we handed the model a three-hour-old snapshot of availability, we'd be right back to trusting stale information, exactly what we decided not to do. The conversation is recoverable; the availability data inside it isn't something we cache and reuse — it gets re-checked, every time, against the live engine.

None of this makes the agent smarter. If anything, the whole design is an exercise in making sure it doesn't need to be. The model's entire contribution is turning "can I book a lesson with Marc next Tuesday morning" into a structured question the booking engine already knows how to answer safely. Everything that actually moves money or holds a slot still goes through the same guarantees we built for the web flow. That was the point from the start: the agent is a new way to talk to Lueira, not a new way to bypass it.