Case study
BizBot
An agentic AI receptionist for appointment-based businesses. A customer messages a hair salon, a mobile detailer, a cleaning company, an auto shop or a tutor over SMS, WhatsApp or web chat, and it checks availability, books, cancels and reschedules against the business's live schedule: an LLM agent with booking tools on WhatsApp, a deterministic state machine calling the same tools on SMS and web chat. The owner controls everything the agent knows from one dashboard and can take any conversation over at any point.
- Role
- Founder and sole engineer
- Period
- 2025 – present
- Stack
- OpenClaw agent runtime, Groq gpt-oss-120b, n8n, Next.js, Supabase Postgres, Twilio
- Status
- Live in production · 13 paying customers
The problem
The stylist is mid-colour, the detailer has both hands in a wheel arch, the cleaner is three rooms into a job. Nobody picks up, and the message that would have been a booking goes nowhere. A language model can answer it, and putting one on the line fails in three ways.
- Grounding. The owner gives a stylist Wednesdays off, the prompt still says nobody is off, and the agent offers Wednesday. The owner finds out when the customer arrives.
- Safe actions. Booking is a write, not a conversation: a row, a calendar event and a confirmation, with every argument starting as free text a stranger typed.
- Cost and control. Every agent turn costs tokens, some customers try to talk the model into something it should not say, and the owner has to be able to take a thread back mid-conversation.
All three had to hold for businesses that schedule nothing alike: a one-person mobile
detailer, a cleaning company that runs three jobs at once, a salon of four stylists
where only one does curly cuts. Those are the product's three booking modes,
solo, capacity and per_staff, and no tenant
ever sees another's data.
What I built
A multi-tenant platform with an agentic core: one tool-calling agent, a deterministic engine beside it, and an owner dashboard that is the only source of what the agent knows.
- A hybrid agent architecture. Only WhatsApp is model-driven, an
OpenClaw agent on Groq's gpt-oss-120b. SMS and web chat run
router.js, 1,841 lines of state machine with numbered menus, explicit refusals and no model in it. - Tools, not prompts, enforce the rules. Both paths call the same
five booking webhooks, and every booking mode writes through
create_booking_rpc, so staff qualifications, overlap and notice windows are decided by Postgres and the workflow above it, never by a prompt. - The agent's facts come from the database. An owner's write re-renders its prompt and skills from that tenant's rows in about 30 seconds. There is no second copy of the truth to go stale.
- Guardrails around every action.
openclaw-call.mjssends customer text as JSON on a quoted heredoc, never a shell string, and holds the webhook secret the prompt never sees. A proxy caps each tenant's request rate and daily spend; the owner can take any conversation over mid-thread. - Tenant isolation at the database. Row-level security on every
tenant table: the dashboard runs on the anon key plus the user's JWT, while n8n
uses
service_rolebecause cross-tenant routing is its job.
In production
From the businesses running on BizBot. Client data is under contract, so these are rounded.
How it works
The model owns exactly one channel. SMS and web chat authenticate the request, resolve the tenant, check for an owner takeover, and run a deterministic state machine; only WhatsApp reaches the agent, whose tool calls are validated before they run. Both paths end in the same five booking webhooks, and a dashboard write reaches the agent's prompt about 30 seconds later.
All three booking modes (solo, fixed capacity, several staff) write through one
Postgres function, create_booking_rpc: it checks qualification, staff
hours, time off and overlap, then inserts, under an advisory lock on the business. The
lock is what stops two customers confirming in the same second from both counting the
slot free; no constraint can, because capacity bookings are supposed to overlap up to
the business's limit. Solo alone runs a free/busy check against the owner's own
calendar first, because the function cannot see it.
check_availability_rpc returns a reason as well as slots, so a closed day
and a day the team is off do not both come back as fully booked.
Owners also run the business by message: a WhatsApp allowlist of seven operations over bookings, hours, services and FAQs, authenticated by the owner's number and logged whether each one ran or was refused.
The hard part
Getting a language model to take real actions against a live business when every argument it passes started as a stranger's free text.
Choosing and measuring the model
Tool calling is where models fail quietly. Llama 3.3 70B and Llama 4 Scout 17B both dropped or malformed booking tool calls; gpt-oss-120b formatted them reliably, at roughly a tenth of the cost of a frontier model. So I stopped guessing at prompts: a 53-scenario harness drives the real model through an agent loop against the real booking tools and asserts on which tools were called, what the reply said, and what landed in Postgres. A pass means the booking exists, not that the model said it did. One day of prompt work took it from 36 of 53 to 46. It has not run since those prompts were replaced.
Keeping the agent grounded
A prompt is a snapshot; a business changes daily. A stylist given Wednesdays off while the prompt still said nobody was off is the failure owners notice. So nobody writes the agent's facts by hand: a dashboard write stamps a sync request, a poller re-renders the workspace from live rows and restarts the gateway, and a test fails if a new config action forgets to ask. One gateway means one workspace, so only the tenant holding the WhatsApp line is rendered. Open slots and anything else time-sensitive come from a tool at the moment of the question.
Making tool calls injection-proof
The agent's tool transport is a shell command. The first skill recipes had the model
paste customer text into a curl line, so a quote or a $(...)
could reach the shell. Tool calls now go through one script: JSON on a quoted heredoc,
which the shell does not expand and a JSON string cannot end, every field validated per
action, the webhook URL and secret held by the script and never in the prompt. Tests run
both shapes through a real shell: the old recipe executes a hostile value, the new one
delivers it as data.
Numbers
Measured in the repo, each next to what produced it.
What I would do differently
- Make the agent stateless across tenants. The gateway holds one
workspace on disk, so it answers as exactly one tenant, and switching stops it,
archives the open WhatsApp sessions and restarts: 30 to 60 seconds of silence. A
get_business_contexttool, or context passed per request, would let one gateway serve every tenant and make a dashboard edit take effect on the next message rather than on a re-render. - Make evals a merge gate from day one. The harness proved itself in a single day of prompt work and has not run since: it targets a demo tenant and prompts that per-tenant rendering has replaced. An eval that asserts on database state belongs in CI beside the unit tests, run on every prompt, skill or model change, with the score tracked over time.
- Use schema-typed function calling end to end. A shell command as the tool transport is safe only because a validator script stands in front of it. Typed tool schemas validated before execution remove that class of problem instead of defending against it.
- One conversation log for every channel. Only the state machine
writes to
conversations, so the owner's inbox, takeover and analytics cover web chat and SMS but not the agent's WhatsApp threads, which live in the runtime's session files. The agent should write to the same log, which is also the dataset the evals should be built from.