The 20% That Is the Business
Yesterday I was testing our new cake-intake agent by pretending to be a customer on WhatsApp. The agent quoted me fine. I sent "paid SS here" with a screenshot. It confirmed. I said I wanted a second cake for the same day. It asked for details. I gave them. It summarised. I said "yes". A second order dropped into the database.
Then I sent "hi". Nothing. Two minutes. Sent another "hi". Still nothing.
I've been writing about adaptive software for a few weeks now. Ship an 80% ready template, let the business shape the last 20% through an AI that edits the product. That post was the thesis. This one is the lived version. A real customer, one day, the five things that broke, and what the 20% that is the business actually looks like in code.
#The customer
CakeInspiration is a premium custom-cake bakery in Singapore. Small team. Leonard and Zai run it, Joanne handles custom orders from Manila, Yana runs the website. Everything else runs on WhatsApp and a 59MB Excel masterfile with 34 tabs. 27 months of bespoke orders plus 5 utility sheets. A money-pulling cake has a surcharge written into a hidden column. The delivery zones are encoded as a formula Leonard wrote in 2022 and hasn't touched since.
This was one of the 22 customers we'd signed up on Envoy, our multi-tenant SaaS CRM. Each one had a unique integration block. WESS for a handful, Koomi for another, a Google Sheets masterfile for CakeInspiration, email clients (Outlook, Gmail, SMTP) for about ten. Twelve unique integrations, five engineers. The math never converged.
So we stopped trying to make one product do all of it.
#The 80%
vobase, the open-source framework we built for this, ships the 80%. Auth with email OTP, a channels abstraction so WhatsApp and email and SMTP all go through one send/receive contract, a Postgres-backed job queue with retries and cron, object storage with local and S3 adapters, SSE realtime, audit logging, an encrypted credential vault.
On top of that, template modules. A full messaging inbox. An AI-agent framework built on Mastra. A knowledge base with RAG. A team-and-role model. None of that is bespoke. It's the same scaffolding whether you're running a clinic, a catering business, or a bakery.
For CakeInspiration this layer gave us a managed WhatsApp channel through Meta, a shared inbox for the team, per-contact working memory, and a knowledge base seeded with real policies scraped from their public site. A few hours of setup.
That's the 80%.
#The 20%
Everything that makes the bakery a bakery lives in one module: modules/orders/.
The schema defines an order with line items. A single order can bundle a tiered wedding cake, matching cupcakes, and a tasting box. There's an accessories column (cake knife and one standard candle included by default). A candle count. A designer field because Joanne needs to see her orders. A route plan table for delivery runs.
The handlers define the state machine: quoting β awaiting_payment β paid β prepping β out_for_delivery β delivered. There's a jobs directory with a day-before delivery reminder, a post-delivery feedback nudge, and an escalation if a customer's been waiting too long. There's a staff page with a printable delivery route sheet that groups stops by Singapore postal sector, because SG addresses are basically solvable from the postal code alone.
And there's one agent: cake_intake. It inherits the generic scaffolding (read a conversation, send a reply, search the KB, reassign to a human) but its domain tools are bespoke: create_draft_order, add_cake_item, classify_intent (is this custom, last-minute, corporate, or a brand collab? they route to different staff), search_cake_catalog. The system prompt has the specific knowledge baked in: the money-pulling cake surcharge, the candle that ships with every cake, the rule that Joanne owns custom orders, the fact that staff never quote a price from the agent, always from the inbox.
The whole module is about a dozen files. The template is hundreds. But the module is what makes it CakeInspiration's software, not a generic CRM with their logo on top.
#Where it broke
I deployed to production yesterday. Seeded data. Pre-invited two admins with emails outside our domain allowlist (Kai and Jiayi, the owners). Sent the first WhatsApp as a customer. Then spent the next few hours fixing five real bugs.
The wrong agent replied. The first message on the managed WhatsApp number routed to an agent called booking, a template leftover from the clinic-appointment starter we'd never cleaned up. It confidently replied "Order Confirmed!" but never created an order, because its tools were book_slot and check_availability, not create_draft_order. No one had noticed it was still in the agent picker.
Bad assignee string. Fixed the routing by pointing the channel at cake_intake. Next message also went nowhere. The assignee column wanted agent:cake_intake, with the prefix. I'd set it to cake_intake bare. The inbound handler saw a human-shaped string and treated it as a human conversation. No agent-wake job fired.
Null channel type. Routing fixed. Staff sent a quote to the customer from the orders page. Delivery failed with "No channel type on message." The quote handler inserted the message into the database but never set channel_type, because the helper it called didn't fill that in. Fix: resolve the channel type from the conversation's channel instance, set it at insert.
Retry still failed. The retry button re-fired delivery on the same row. Same error. Because the row's channel_type was still null, and the insert-site fix only helped new rows. Fix: in processDelivery, if the message's own channel type is null, resolve it from the conversation and backfill.
The agent wrote a reply but didn't send it. Customer sent a multi-line order spec. Agent-wake ran for 4 seconds. No error in the logs. The LLM had generated 389 characters of perfectly good reply text. It just hadn't called send_reply. Sonnet occasionally does this on post-intake clarification turns. The system prompt says "ALWAYS call send_reply, if you don't the customer sees nothing" in bold. The model ignored it.
#The safety net
The last one was the bug that felt most like an adaptive-software problem. You can't prompt-engineer this out reliably. You can't leave a customer hanging because the LLM forgot to invoke a tool.
So I wrote a safety net into agent-wake.ts. After agent.generate() returns, check the database: did this run produce any outbound message from this agent on this conversation? If no, and the result has non-empty text, post it ourselves and enqueue delivery.
const text = ((result as { text?: unknown }).text ?? '').toString().trim();
if (text) {
const [sentDuringRun] = await db.select({ id: messages.id })
.from(messages)
.where(and(
eq(messages.conversationId, conversationId),
eq(messages.messageType, 'outgoing'),
eq(messages.senderType, 'agent'),
eq(messages.senderId, agentId),
gte(messages.createdAt, runStartedAt),
))
.limit(1);
if (!sentDuringRun) {
logger.warn('[agent-wake] No send_reply call, auto-sending agent text');
const msg = await insertMessage(db, realtime, {
conversationId, content: text, channelType,
senderId: agentId, senderType: 'agent',
messageType: 'outgoing', contentType: 'text', status: 'queued',
});
await enqueueDelivery(scheduler, msg.id);
}
}
Shipped it. Re-tested. Within ten minutes the safety net fired once on a real conversation, logged a warning, posted the 389 characters that otherwise would have been lost, and the flow kept going. The second order was created, quoted, and paid.
#What the 20% actually is
Five files, one schema, one agent, one safety net. The template handles the other 80%. If the bakery tells us next week that they want dynamic pricing based on delivery zone, or a shift roster for the kitchen, or a webhook into their Instagram DMs, the shape of the work is the same. One module, a handful of files, a prompt change, maybe a safety net if something silently fails.
When I wrote about the death of SaaS last year, the argument was about the business model: when the cost of software falls to zero, selling subscriptions to tools stops making sense. What I was less sure about then was the product shape. I think the last 24 hours answered that for me. A multi-tenant CRM couldn't model any of this. Every shortcut it tries to make (shared schema, shared prompts, shared tenant boundary) crashes into one specific bakery's one specific constraint. The shape that works is a template that handles the common 80%, plus a thin custom layer that lives with the business.
We're still early. The CakeInspiration team demo is next week. There are rough edges I already know about. The inbox still shows the old product name in a few places, the delivery sheet doesn't handle self-collect properly, staff still have to manually verify payment screenshots because I disabled the vision flow after too many false positives. But the first order flow works end to end on WhatsApp, SC0001 and SC0002 are both paid in production, and the safety net caught a real LLM miss within ten minutes of shipping.
That's enough to keep going.