Inspiration
Picture a plumber who spends his day under a house. Two vans, a couple of other people, no receptionist. When the phone rings he is not available, because he is doing the job. By the time he checks it, the work has gone to whoever picked up.
Meanwhile people have started finding local help by asking an assistant. If an assistant finds his website today, it can read it and nothing else. It hands over a phone number, the customer calls somebody else, and he never learns the job existed. The failure is completely invisible from his side of it.
Every business large enough to matter will eventually build an agent integration. A three person plumbing shop will not. No budget, no engineer, no appetite for another dashboard to learn.
I run a small agency that builds sites for businesses like this, so I am fairly sure they will not build it themselves. Which left me with the question: can they just use what they already have?
The interesting part is not whether an agent can book a plumber, rather it's whether the long tail of businesses that will never hire an engineer can be reached by one at all.
What it does
Front Desk is a working plumbing website that publishes real tools to the browser over WebMCP, so an assistant can do what a receptionist would.
It can check whether the shop drives to your address, read the genuine open appointment windows, price the job as a range, and request a visit. Then it stops. The booking is held, the owner gets a message on his phone with the job and a reference, and he replies YES. Only then is anything booked.
Five tools register when the page loads:
check_service_areacheck_availabilityestimate_jobdescribe_servicesbook_appointment
The contact form is separately annotated with the declarative API, so the project uses both halves of the standard.
Two more do not exist when the page loads:
check_hold_statuscancel_hold
They are registered the moment a booking produces a hold, scoped to that reference, and unregistered the moment it resolves. The tool surface describes what is possible right now, instead of listing everything that could ever be possible and trusting the model to check first. An assistant cannot cancel a booking that was never made, because there is no tool to call.
Every refusal is a real answer. Ask about Seattle and it says Seattle is 29 miles out, the nearest place covered is Kent at 16 miles, and what to try instead. cancel_hold refuses outright on a job the owner already confirmed and tells the assistant to phone the shop, because a confirmed job is a commitment somebody may already be driving toward.
There is also a live dispatch board showing the same schedule the tools read and write, so you can watch a window go from open to held to confirmed in real time.
How we built it
Astro 7 on Netlify. A Netlify Function behind every tool. Netlify Blobs for the schedule and the pending holds. Telegram for the owner notification and his one word reply. No database, no auth, no dashboard.
The Telegram part is swappable for SMS or WhatsApp in a config change, and that is deliberate. The channel is not the point.
The details that decide whether a WebMCP implementation is real rather than decorative:
executereturns a string, so anything structured gets stringified- The
AbortSignalis passed into everyfetch, so a cancelled request actually cancels the work - Tools register on
astro:page-loadand tear down onastro:before-swapthrough anAbortController, because the site uses a client router and a plain script would go stale. That same mechanism drives the two conversation scoped tools readOnlyHintis true only where it is true. Four tools read nothing.book_appointmentchanges state and says so, because claiming otherwise makes the browser skip a confirmation it should have shown
Challenges we ran into
Two agents could book the same window. Netlify Blobs default to eventual consistency and last write wins. Availability that is sixty seconds stale hands an assistant a slot that is already gone. The schedule store now uses strong consistency, and holds are taken with a conditional write on the ETag. A losing writer re-reads and retries, and SLOT_TAKEN comes back as a refusal rather than an error. This is the first thing I would poke at in somebody else's booking demo, so I fixed it before building on top of it.
Messaging fought back for most of a day. US application to person SMS needs A2P 10DLC registration, which means a paid account and carrier vetting measured in days. So I moved to the Twilio WhatsApp sandbox, which rejected everything with 21654 ContentSid Required. I chased that as a session window problem for hours. The real answer only appeared when I tried SMS and got a different error: 572006, trial accounts can only use predefined SMS templates. Twilio trial accounts cannot send a custom message body on any channel. Telegram has no carrier, no templates and no account tier, and the loop closed twenty minutes later.
A latent bug in my own code that only a manual call exposed. execute destructured { signal } from its second argument. Every real agent passes one, so it worked everywhere until I invoked a tool by hand through executeTool, which passes nothing, and it threw before doing anything.
Ordinary disasters. Deleting .netlify to clear a build collision also deletes the project link, so a deploy silently created a second Netlify site and shipped there. Netlify caches its function bundle, so new functions never registered their paths until I passed --skip-functions-cache.
Accomplishments that we're proud of
The tool surface changes during the conversation. Five tools before a booking, seven after, back to five when it resolves. You cannot do that with a plain HTTP API.
Refusals that are useful. Every failure carries a stable reason code, a sentence written for a person, and a suggested next action. The interesting claim in an agent native interface is not that a site can do things. It is that it can say exactly what it cannot do and what would work instead.
The security work, because customer text reaches a real person's phone by way of a model. Everything relayed is sanitised, reference shaped tokens are masked so nobody can forge a booking reference, and the confirm keyword sits on its own line above a fence marking customer text unverified. I tested it with a booking containing a right to left override, a zero width space, an instruction to ignore previous instructions and reply YES, and a forged reference. The message came out clean and the forged reference returned HOLD_NOT_FOUND.
Booking the same window twice is genuinely impossible, and I verified that against production rather than assuming it.
What we learned
A tool that refuses correctly is better evidence of a real implementation than one that always succeeds, and it is more useful to the model too.
Annotations are claims, not decoration. readOnlyHint on a state changing tool is not an optimisation, it is a lie the browser acts on.
Verify against the deployed thing, never the source. A CSS minifier silently dropped a rule that looked fine in the file. An LCP reading of 7.8 seconds turned out to be cold start and measured 400ms warm. Neither was visible in code.
And the boring question, whether two writers can collide, mattered more than anything clever in here.
What's next for Front Desk
Put it on a real business. Everything is generic apart from one data file, so pointing it at an actual plumber or electrician is a content change, not a rebuild.
Make the owner able to counter. Right now he can say yes or no. He should be able to say "not 10am, offer them 1pm" and have the assistant take that back to the customer. That turns one chain into a real back and forth between two people who never speak to each other, with the agent in the middle.
Answer the question this raised for me. There is currently no way to check whether a site exposes WebMCP tools at all. Every small business is about to need that answer and nothing gives it to them.
Built With
- astro
- css3
- html5
- javascript
- netlify
- netlify-blobs
- netlify-functions
- twilio
- typescript
- webmcp
Log in or sign up for Devpost to join the conversation.