Introduction
Here’s a fourth, production-ready pattern you can drop into your stack: a Corporate Travel Booking Agent. It reads a traveler’s request, checks company travel policy and budget, searches itineraries, holds the best compliant option, and—only with approval or within self-serve limits—purchases the ticket. It never claims success unless the backend executed the action and returned a receipt.
The Use Case
Employees ask for flights (origin, destination, dates, cabin, constraints). The agent should enforce policy (max fare, allowed cabins by route, preferred carriers), handle approvals for over-policy cases, and return a single held or purchased itinerary with a confirmation reference and clear next steps.
Prompt Contract (agent interface)
# file: contracts/travel_booking_v1.yaml
role: "TravelBookingAgent"
scope: >
Plan and book business travel within policy. Ask once for missing fields
(user_id, origin, destination, depart_date, return_date, cabin).
Prefer preferred carriers; enforce fare caps; propose tools and never assert
success unless a receipt is present.
output:
type: object
required: [summary, decision, itinerary, citations, next_steps, tool_proposals]
properties:
summary: {type: string, maxWords: 70}
decision: {type: string, enum: ["search_only","hold","purchase","need_approval","need_more_info"]}
itinerary:
type: object
required: [origin, destination, depart, return, carrier, cabin, price_cents]
properties:
origin: {type: string}
destination: {type: string}
depart: {type: string}
return: {type: string}
carrier: {type: string}
cabin: {type: string}
price_cents: {type: integer}
citations: {type: array, items: {type: string}}
next_steps: {type: array, items: {type: string}, maxItems: 6}
tool_proposals:
type: array
items:
type: object
required: [name, args, preconditions, idempotency_key]
properties:
name: {type: string, enum: [GetPolicy, SearchFlights, HoldItinerary, EnsureApproval, PurchaseTicket]}
args: {type: object}
preconditions: {type: string}
idempotency_key: {type: string}
policy_id: "travel_policy.v6.us"
citation_rule: "1–2 minimal-span claim_ids per factual sentence"
decoding:
narrative: {top_p: 0.92, temperature: 0.72, stop: ["\n\n## "]}
bullets: {top_p: 0.82, temperature: 0.45}
Example claims (context given to the model)
[
{"claim_id":"policy:farecap:domestic","text":"Domestic roundtrip fare cap is $700 all-in.",
"effective_date":"2025-06-01","source_id":"doc:travel_policy_v6","span":"$700 all-in"},
{"claim_id":"policy:cabin:domestic","text":"Domestic flights must be Economy; Premium Economy allowed if >4 hours.",
"effective_date":"2025-06-01","source_id":"doc:travel_policy_v6","span":"Economy; Premium Economy >4h"},
{"claim_id":"policy:preferred:carrier","text":"Preferred carriers are BlueSky and AeroLine.",
"effective_date":"2025-06-01","source_id":"doc:travel_policy_v6","span":"Preferred carriers"},
{"claim_id":"policy:approval:overcap","text":"Over-cap fares require manager approval.",
"effective_date":"2025-06-01","source_id":"doc:travel_policy_v6","span":"require manager approval"}
]
Tool Interfaces (typed, with receipts)
# tools.py
from pydantic import BaseModel
from datetime import date, datetime, timedelta
from typing import Optional, Dict, List
class GetPolicyArgs(BaseModel):
user_id: str
route_type: str # "domestic" | "international"
class SearchFlightsArgs(BaseModel):
origin: str
destination: str
depart_date: date
return_date: date
cabin: str # "economy" | "premium_economy" | "business"
preferred_carriers: Optional[List[str]] = None
max_results: int = 5
class HoldItineraryArgs(BaseModel):
user_id: str
carrier: str
price_cents: int
cabin: str
origin: str
destination: str
depart_iso: str
return_iso: str
hold_minutes: int = 24*60
class EnsureApprovalArgs(BaseModel):
user_id: str
manager_id: str
reason: str
price_cents: int
class PurchaseTicketArgs(BaseModel):
hold_ref: str
user_id: str
class ToolReceipt(BaseModel):
tool: str
ok: bool
ref: str
message: str = ""
data: Optional[Dict] = None
# adapters.py (demo logic)
from tools import *
from datetime import date, datetime, timedelta
PREFERRED = ["BlueSky","AeroLine"]
FARE_CAP_DOMESTIC = 70000 # cents
def get_policy(a: GetPolicyArgs) -> ToolReceipt:
return ToolReceipt(tool="GetPolicy", ok=True, ref=f"pol-{a.user_id}",
data={"fare_cap_cents": FARE_CAP_DOMESTIC if a.route_type=="domestic" else 300000,
"preferred": PREFERRED,
"cabin_rules": "economy; premium_economy if >4h domestic"})
def search_flights(a: SearchFlightsArgs) -> ToolReceipt:
# mock 2 results; in real life call GDS/OTA/airline API
results = [
{"carrier":"BlueSky","price_cents": 58000,"cabin": "economy",
"depart_iso": f"{a.depart_date}T09:10:00","return_iso": f"{a.return_date}T17:35:00",
"origin": a.origin, "destination": a.destination},
{"carrier":"AeroLine","price_cents": 74500,"cabin": "premium_economy",
"depart_iso": f"{a.depart_date}T08:00:00","return_iso": f"{a.return_date}T18:20:00",
"origin": a.origin, "destination": a.destination}
]
return ToolReceipt(tool="SearchFlights", ok=True, ref="search-rt", data={"results": results[:a.max_results]})
def hold_itinerary(a: HoldItineraryArgs) -> ToolReceipt:
expires_at = (datetime.utcnow() + timedelta(minutes=a.hold_minutes)).isoformat()
return ToolReceipt(tool="HoldItinerary", ok=True, ref=f"hold-{a.carrier}-{a.origin}{a.destination}",
data={"expires_at": expires_at, "price_cents": a.price_cents})
def ensure_approval(a: EnsureApprovalArgs) -> ToolReceipt:
# pretend manager approved; return false sometimes in real impl
approved = a.price_cents <= 90000 # approve if ≤ $900 for demo
return ToolReceipt(tool="EnsureApproval", ok=approved,
ref="appr-123" if approved else "appr-denied",
message="Approved" if approved else "Denied")
def purchase_ticket(a: PurchaseTicketArgs) -> ToolReceipt:
return ToolReceipt(tool="PurchaseTicket", ok=True, ref=f"tkt-{a.hold_ref}",
data={"ticket_number": "880-1234567890"})
Agent Loop (proposal → verification → execution → receipts)
# agent_travel.py
import uuid, json
from datetime import date
from typing import Any, Dict, List
from tools import *
from adapters import *
ALLOWED_TOOLS = {"GetPolicy","SearchFlights","HoldItinerary","EnsureApproval","PurchaseTicket"}
def new_idem() -> str:
return f"idem-{uuid.uuid4()}"
def verify_proposal(p: Dict[str, Any]) -> str:
required = {"name","args","preconditions","idempotency_key"}
if not required.issubset(p): return "Missing proposal fields"
if p["name"] not in ALLOWED_TOOLS: return "Tool not allowed"
if p["name"] == "PurchaseTicket" and "hold_ref" not in p["args"]:
return "Purchase requires hold_ref"
return ""
def execute(p: Dict[str, Any]) -> ToolReceipt:
n, a = p["name"], p["args"]
if n == "GetPolicy": return get_policy(GetPolicyArgs(**a))
if n == "SearchFlights":
a["depart_date"] = date.fromisoformat(a["depart_date"])
a["return_date"] = date.fromisoformat(a["return_date"])
return search_flights(SearchFlightsArgs(**a))
if n == "HoldItinerary": return hold_itinerary(HoldItineraryArgs(**a))
if n == "EnsureApproval": return ensure_approval(EnsureApprovalArgs(**a))
if n == "PurchaseTicket": return purchase_ticket(PurchaseTicketArgs(**a))
return ToolReceipt(tool=n, ok=False, ref="none", message="Unknown tool")
# --- Model shim returning a plan per contract (replace with your LLM call) ---
def call_model(contract_yaml: str, claims: List[Dict[str,Any]], req: Dict[str,Any]) -> Dict[str,Any]:
# pretend the model chose the preferred, in-cap itinerary
itinerary = {
"origin": req["origin"], "destination": req["destination"],
"depart": f"{req['depart_date']}T09:10:00","return": f"{req['return_date']}T17:35:00",
"carrier": "BlueSky", "cabin": "economy", "price_cents": 58000
}
decision = "purchase" if itinerary["price_cents"] <= 70000 else "need_approval"
return {
"summary": f"Found a preferred-carrier economy roundtrip within cap; proceeding to hold then purchase.",
"decision": decision,
"itinerary": itinerary,
"citations": ["policy:preferred:carrier","policy:farecap:domestic","policy:cabin:domestic"],
"next_steps": ["Fetch policy", "Search flights", "Hold itinerary", "Purchase if within cap or approved"],
"tool_proposals": [
{"name":"GetPolicy","args":{"user_id":req["user_id"],"route_type":"domestic"},
"preconditions":"Load fare caps and preferred carriers.","idempotency_key": new_idem()},
{"name":"SearchFlights",
"args":{"origin":req["origin"],"destination":req["destination"],
"depart_date":req["depart_date"],"return_date":req["return_date"],
"cabin":req.get("cabin","economy"),"preferred_carriers":["BlueSky","AeroLine"],"max_results":5},
"preconditions":"Find compliant options; prefer preferred carriers.","idempotency_key": new_idem()},
{"name":"HoldItinerary",
"args":{"user_id":req["user_id"],"carrier":"BlueSky","price_cents":58000,"cabin":"economy",
"origin":req["origin"],"destination":req["destination"],
"depart_iso":f"{req['depart_date']}T09:10:00","return_iso":f"{req['return_date']}T17:35:00",
"hold_minutes":1440},
"preconditions":"Hold best compliant option.","idempotency_key": new_idem()},
{"name":"EnsureApproval",
"args":{"user_id":req["user_id"],"manager_id":req["manager_id"],"reason":"Travel booking over cap",
"price_cents":58000},
"preconditions":"Approval required only if price exceeds cap.","idempotency_key": new_idem()},
{"name":"PurchaseTicket",
"args":{"hold_ref":"hold-BlueSky-"+req["origin"]+req["destination"],"user_id":req["user_id"]},
"preconditions":"Only if within cap or approval ok.","idempotency_key": new_idem()}
]
}
def render_response(model_json: Dict[str,Any], receipts: List[ToolReceipt]) -> str:
idx = {r.tool:r for r in receipts}
lines = [model_json["summary"], ""]
it = model_json["itinerary"]
lines.append(f"Itinerary: {it['origin']} → {it['destination']} ({it['depart']} / {it['return']})")
lines.append(f"Carrier {it['carrier']}, cabin {it['cabin']}, price ${it['price_cents']/100:.2f}")
lines.append("")
lines.append(f"Decision: {model_json['decision']}")
lines.append("Next:")
for s in model_json["next_steps"]: lines.append(f"• {s}")
if idx.get("HoldItinerary") and idx["HoldItinerary"].ok:
lines.append(f"\nHeld: {idx['HoldItinerary'].ref}, expires {idx['HoldItinerary'].data['expires_at']}")
if idx.get("PurchaseTicket") and idx["PurchaseTicket"].ok:
lines.append(f"Purchased: ticket {idx['PurchaseTicket'].data['ticket_number']} (ref {idx['PurchaseTicket'].ref})")
lines.append("\nCitations: " + ", ".join(model_json["citations"]))
return "\n".join(lines)
def handle(req: Dict[str,Any]) -> str:
contract = open("contracts/travel_booking_v1.yaml").read()
claims: List[Dict[str,Any]] = [] # load real policy claims per region/company
plan = call_model(contract, claims, req)
receipts: List[ToolReceipt] = []
for prop in plan["tool_proposals"]:
reason = verify_proposal(prop)
if reason:
receipts.append(ToolReceipt(tool=prop["name"], ok=False, ref="blocked", message=reason)); continue
rec = execute(prop)
receipts.append(rec)
if not rec.ok and prop["name"] in {"PurchaseTicket"}:
break
return render_response(plan, receipts)
if __name__ == "__main__":
request = {
"user_id":"U314",
"manager_id":"M271",
"origin":"SFO",
"destination":"SEA",
"depart_date":"2025-11-05",
"return_date":"2025-11-07",
"cabin":"economy"
}
print(handle(request))
The Prompt You’d Send to the Model (concise and testable)
System:
You are TravelBookingAgent. Follow the contract:
- Ask once if user_id, origin, destination, dates, or cabin are missing.
- Cite 1–2 claim_ids per factual sentence using provided claims.
- Propose tools; never assert success without a receipt.
- Output JSON with keys: summary, decision, itinerary{}, citations[], next_steps[], tool_proposals[].
Claims (eligible only):
[ ... JSON array of travel policy claims like above ... ]
User:
Book me a round trip from SFO to SEA Nov 5–7, economy, user U314, manager M271.
How to adapt quickly
Wire search_flights
, hold_itinerary
, and purchase_ticket
to your GDS/OTA or airline APIs; keep idempotency and holds before purchase. Load policy claims from your travel policy with fare caps, preferred carriers, and cabin rules by route type and region. Add a validator step before execution for schema, lexicon, locale, citation coverage, “no implied writes,” and budget/SLO adherence. Ship the contract, policy bundle, and decoder settings as a bundle behind a feature flag + canary + rollback.