Venue quote agent demo

Demo · Agent + structured extraction

An agent that asks wedding venues for prices

Venues hide prices behind inquiry forms. This project built an agent to fill those forms and a Claude extraction step to turn the replies into comparable quotes. Here it runs end to end on mock venues and synthetic replies, with every step shown.

  • 6 of 7mock forms submitted
  • 43fields filled across 71 recorded steps
  • 1stopped at a captcha, on purpose
  • 5venue replies through the extractor
localhost:4455/venue-7
Rosewater Pavilion mock form, filled in by the agent and ready to submit
Rosewater Pavilion, a mock form, filled by the agent: name split in two, the date spread across month, day and year dropdowns. Recorded, not staged.

Part 1 · The form agent

Replay: filling seven mock inquiry forms

A recorded run of runInquiry() from scripts/agent/inquire.ts, unchanged except for a screenshot after each step, against the project's mock venue server. 6 of 7 forms went through. The one behind a captcha stopped, by design. The persona is a labeled test (Demo Agent, demo-agent@example.com), not the couple persona the code ships with.

Twilight Gardens mock venue 1 of 7

Minimal: one name field, date input.

localhost:4455/venue-1
Twilight Gardens, step 1: Opened /venue-1Twilight Gardens, step 2: Found the inquiry formTwilight Gardens, step 3: Typed "Demo Agent"Twilight Gardens, step 4: Typed "demo-agent@example.com"Twilight Gardens, step 5: Typed "555-010-0199"Twilight Gardens, step 6: Typed "2027-08-14"Twilight Gardens, step 7: Typed the messageTwilight Gardens, step 8: Ready to submitTwilight Gardens, step 9: Submitted. Outcome: success
Step 1 of 9
What the mock server received
{
  "name": "Demo Agent",
  "email": "demo-agent@example.com",
  "phone": "555-010-0199",
  "wedding_date": "2027-08-14",
  "message": "Automated demo inquiry from a test agent, sent to a mock form on localhost. Not a real couple and not a real event."
}
Every run at a glance (recorded 2026-10-26)
Mock venueWhat makes the form trickyPages triedFields filledOutcome
Twilight GardensMinimal: one name field, date input15Submitted
Ironside HallFirst and last name split, number input for guests, an extra field to skip17Submitted
The Pritz RoomDropdowns for event type and guest-count range17Submitted
Harbor LoftNo inquiry form on the homepage, only a newsletter box; the form is on /contact37Submitted
Lakeview EstateForm behind an hCaptcha widget10Stopped: captcha
Cedar Point ManorPhone split into area code and number17Submitted
Rosewater PavilionWedding date as month, day and year dropdowns, plus an event type dropdown110Submitted

Part 2 · Reading the replies

Venue replies in, structured quotes out

Five synthetic venue replies from the project's test fixtures, each run through the extraction in scripts/agent/parse-and-store.ts: the same prompt, the same Zod schema and the same messages.parse call the pipeline uses. Run on 2026-10-26 with claude-sonnet-4-6. Total API cost: $0.0356. Each output is the raw parsed_output from one real call, saved as returned.

Three fixtures use the names of real Asheville-area venues (The Crest Center Pavilion, Highland Lake Inn and Sourwood Inn) next to invented prices. This page swaps in made-up names and moves sender domains to .example. Nothing else in the text changed.

A clear quote

Flat site fee, an itemized list of what it includes, a deposit and a lead time. The easy case.

The reply (fixture)

Source: scripts/agent/responses/__fixtures__/clear-price-quote.txt (venue name swapped, see note)

What Claude extracted

Auto-reply gate: passed. Goes to Claude.

claude-sonnet-4-61922 in / 166 out tokens$0.0083
{
  "venueName": "The Fern Hollow Pavilion",
  "contactName": "Lauren Cabrera",
  "priceMin": 7500,
  "priceMax": 7500,
  "pricePerGuest": null,
  "packagesIncluded": [
    "exclusive use of Pavilion and outdoor terrace",
    "day-of venue manager",
    "60-inch round tables",
    "Chiavari chairs",
    "white linens",
    "house sound system",
    "handheld mic",
    "one hour rehearsal time",
    "on-site parking",
    "bridal suite"
  ],
  "seasonalNotes": "Saturday Peak Season (May–October) site fee is $7,500 flat; no off-peak pricing was mentioned.",
  "leadTimeMonths": 12,
  "deposit": 2500,
  "parserConfidence": 0.95
}

A vague "starts at" quote

A floor price for a weekday event and a loose range for weekends. The prompt says to report only what was stated.

The reply (fixture)

Source: scripts/agent/responses/__fixtures__/vague-starting-from-quote.txt (venue name swapped, see note)

What Claude extracted

Auto-reply gate: passed. Goes to Claude.

claude-sonnet-4-61715 in / 96 out tokens$0.0066
{
  "venueName": "Sweetgum Inn",
  "contactName": "Riley",
  "priceMin": 4200,
  "priceMax": 15000,
  "pricePerGuest": null,
  "packagesIncluded": [],
  "seasonalNotes": "Peak pricing applies during April–June and September–October; weekday events start lower than weekend celebrations.",
  "leadTimeMonths": null,
  "deposit": null,
  "parserConfidence": 0.75
}

Packages, no price

Lists what the package includes, then asks for a tour instead of naming a number.

The reply (fixture)

Source: scripts/agent/responses/__fixtures__/package-only-no-price.txt

What Claude extracted

Auto-reply gate: passed. Goes to Claude.

claude-sonnet-4-61730 in / 107 out tokens$0.0068
{
  "venueName": "Hidden Meadow Barn",
  "contactName": "Jordan",
  "priceMin": null,
  "priceMax": null,
  "pricePerGuest": null,
  "packagesIncluded": [
    "ceremony lawn",
    "reception barn",
    "bartender service",
    "tables",
    "chairs",
    "linens",
    "glassware",
    "china",
    "day-of coordinator",
    "setup and breakdown"
  ],
  "seasonalNotes": null,
  "leadTimeMonths": null,
  "deposit": null,
  "parserConfidence": 0.2
}

An auto-reply

A canned acknowledgement 30 seconds after the form went in. The pipeline should drop it before any model call.

The reply (fixture)

Source: scripts/agent/responses/__fixtures__/auto-reply.txt (venue name swapped, see note)

What Claude extracted

Auto-reply gate: skipped. subject matches /we(?:['’]ve)? received your/. In the pipeline this email never reaches Claude. It was sent anyway for this demo, to show what the model makes of it.

claude-sonnet-4-61648 in / 79 out tokens$0.0061
{
  "venueName": "Kettle Lake Inn",
  "contactName": "Kettle Lake Inn Events",
  "priceMin": null,
  "priceMax": null,
  "pricePerGuest": null,
  "packagesIncluded": [],
  "seasonalNotes": null,
  "leadTimeMonths": null,
  "deposit": null,
  "parserConfidence": 0.1
}

A reply in Spanish

Same kind of quote, written in Spanish. The schema and prompt are English only.

The reply (fixture)

Source: scripts/agent/responses/__fixtures__/non-english-quote.txt

What Claude extracted

Auto-reply gate: passed. Goes to Claude.

claude-sonnet-4-61853 in / 154 out tokens$0.0079
{
  "venueName": "Hacienda Magnolia",
  "contactName": "María Elena Rodríguez",
  "priceMin": 5800,
  "priceMax": 5800,
  "pricePerGuest": null,
  "packagesIncluded": [
    "exclusive garden and main hall access",
    "Tiffany gold chairs (up to 150 guests)",
    "round tables",
    "white linens",
    "day-of coordinator",
    "parking for 60 vehicles"
  ],
  "seasonalNotes": "The $5,800 venue fee applies to Saturday weddings during peak season (May through October); off-peak pricing was not specified.",
  "leadTimeMonths": 10,
  "deposit": 1500,
  "parserConfidence": 0.85
}

Part 3 · The contract

The schema, and what it rejects

The SDK turns this Zod schema into JSON Schema for the API's structured output mode, then checks Claude's answer against the Zod schema again before the code sees it. Every field is required; "not stated" has to be an explicit null or an empty list.

VenueQuoteExtractionSchema (scripts/agent/parse-and-store.ts)

export const VenueQuoteExtractionSchema = z.object({
  venueName: z.string().describe('Venue name as it appears in the email signature or header'),
  contactName: z
    .string()
    .nullable()
    .describe('Display name of the person replying (e.g. event coordinator). Null if not stated.'),
  priceMin: z
    .number()
    .nullable()
    .describe('Lowest quoted rental/venue fee in USD. Null if only per-person pricing or no concrete numbers were given.'),
  priceMax: z
    .number()
    .nullable()
    .describe('Highest quoted rental/venue fee in USD. Null if only a single flat price was given (set priceMin = priceMax for a flat fee).'),
  pricePerGuest: z
    .number()
    .nullable()
    .describe('Per-guest cost in USD. Null if pricing is venue-fee only or not mentioned.'),
  packagesIncluded: z
    .array(z.string())
    .describe('What the base price includes (tables, chairs, linens, day-of coordinator, etc.). Empty array if not mentioned.'),
  seasonalNotes: z
    .string()
    .nullable()
    .describe('Free-text note describing how pricing varies across seasons (peak/off-peak/weekday/weekend). Null if no seasonality was mentioned.'),
  leadTimeMonths: z
    .number()
    .nullable()
    .describe('Approximate months in advance the venue recommends booking (e.g. 12 for "we book a year out"). Null if not stated.'),
  deposit: z
    .number()
    .nullable()
    .describe('Reservation deposit amount in USD. Null if not stated.'),
  parserConfidence: z
    .number()
    .min(0)
    .max(1)
    .describe(
      'Model self-reported extraction confidence as a 0..1 number. ≥0.8 = explicit numeric prices stated. 0.4-0.79 = inferred from ranges, examples, or comparable packages. <0.4 = no concrete pricing in the reply.'
    ),
});

Five hand-written answers through the same parser

SampleResultZod's message
A well-formed answerAcceptedParses into the typed object.
Price written as text ("$4,200")RejectedpriceMin: invalid_type, expected number, got a string
Confidence above 1RejectedparserConfidence: too_big, maximum 1
A required field left outRejectedpackagesIncluded: invalid_type, expected array, key missing
Field omitted instead of nullRejecteddeposit: invalid_type, expected number, key missing

One thing the API does not enforce. JSON Schema sent for structured output carries types, not numeric bounds. The SDK moves .min(0).max(1) on parserConfidence into the field's description as {minimum: 0, maximum: 1}. So a confidence of 1.4 could come back from the API, and only the Zod check on the client would catch it.

Produced by scripts/demo/validation-cases.ts, no API call.

How it works

The pipeline, as built

  1. 1

    Find the form

    Open the venue site. Check for a captcha first. Score every form on the page and pick the most inquiry-shaped one, or try common paths like /contact.

    scripts/agent/inquire.ts

  2. 2

    Fill it

    Classify each field from its label, aria-label, placeholder, name and id. Split dates and phone numbers when the form does. Submit and look for a confirmation.

    scripts/agent/inquire.ts

  3. 3

    Catch the reply

    Poll Gmail for mail from venues it contacted. Drop auto-replies by subject line, or by arriving within 60 seconds of the submission.

    scripts/agent/parse-and-store.ts

  4. 4

    Extract the quote

    One Claude call per reply with a Zod structured-output schema: price range, per-guest price, packages, seasonality, lead time, deposit, contact, confidence.

    scripts/agent/parse-and-store.ts

  5. 5

    Review, then publish

    Each quote lands in Firestore as "parsed" and waits in an admin queue. Approved quotes feed the couple-facing venue and compare pages.

    app/admin/quotes

Only steps 1, 2 and 4 ran for this page: the agent against mock forms, and the extraction against fixture text. PDF attachments: parser.ts downloads them to disk, but neither script sends them to Claude, so brochure prices in a PDF would be missed.

Honest ending

Why it never ran for real

All 20 build tickets shipped in June 2026: agent, parser, admin queue, public pages, dashboards. It never collected a single real quote. From the project's own records:

  1. It had no venues to contact.

    The discovery run needed a Google Maps API key and a person to run it. It sat blocked from 2026-05-25 and was split into a prep half and an operator half on 2026-06-08. The operator half never ran: the Asheville seed file is still an empty list, and the targets and results folders are empty.

    Source: ideas/reshape-wedding-venue-quotes-scope.md, projects/wedding-venue-quotes/src/data/venues-asheville.json

  2. The kill date passed without a decision.

    The README set 2026-09-15 as the kill date: 50 quotes collected, 5 tour bookings a month and a 30% venue reply rate. A traction review was filed to pull those numbers. The README status still shows "Measuring" and "Decision" unchecked, and the last traction probe (2026-07-06) has every signal empty.

    Source: projects/wedding-venue-quotes/README.md, ideas/wedding-venue-quotes-traction-review.md, docs/status/traction.json

  3. The default persona poses as a couple.

    The idea doc talks about venues refusing "AI-disclosed inquiries". The code does not disclose: the default persona and message read as an engaged couple. It also rotates real browser user agents and has an optional slow-typing mode (the code comments say the user agents are there to look like a normal visitor and the slow typing is for picky form validators, not stealth). Sending a fake couple's inquiry to small businesses wastes their staff time under a false pretense, so the real-world run was deliberately not done, and this demo uses a persona named "Demo Agent".

    Source: ideas/wedding-venue-quotes.md, scripts/agent/config.ts (persona), scripts/agent/inquire.ts (humanTyping)

  4. Captchas would have stopped it anyway on some venues.

    The scope's own risk register expected Turnstile and hCaptcha on higher-end venues, with manual outreach as the fallback. The agent detects them and stops, as the Lakeview Estate replay shows.

    Source: docs/scopes/wedding-venue-quotes.md (Risks / unknowns)

File paths are in the private Clawset repo, at commit 3aca86b27 (2026-06-20) for the project and at main for the idea and status files.