← Blog

Email automation

Email agent for veterinary pharma: 1,640 weekly questions

A 27-person veterinary-pharma distributor was drowning in 1,640 weekly order questions. We put an email agent in front of SAP Business One without touching the legacy stack.

Jacob Molkenboer· Founder · A Brand New Company· 15 Jun 2026· 9 min
Linen tray with ivory envelopes tied in green ribbon, brass letter opener, red wax seal on bone-paper desk.

The first time we saw the inbox, it was 09:14 on a Tuesday and the customer-service lead had 312 unread messages. She had been at her desk since 07:30. The Nijmegen office of a veterinary-pharma distributor (27 people, third generation, family-owned) was processing 1,640 ordering questions a week through one shared mailbox and a team of four. By Thursday afternoon they were always behind. By Friday they were sending apology emails about apology emails.

What made it stranger: every order eventually became a clean SAP Business One sales order. The information was there. The mailbox just could not see it.

The stack we were not allowed to touch

SAP Business One, on premises, version 9.2, last upgraded in 2021. DocuWare for invoice and certificate archive. A bespoke PHP middleware written in 2013 that synchronised stock between SAP and a Magento 1.9 webshop the team had been promising to retire for six years. A Postfix relay handling outbound mail. Internal users on Outlook 365.

The brief from the operations director on day one was unambiguous. "We do not have appetite for a SAP project this year. Or next year."

Fine. Email agents do not need to live inside SAP. They need to live in front of it.

Where shared-inbox tools fall short

The off-the-shelf shared-inbox tools (Front, Missive, Help Scout) all solve the assignment problem. None of them solve the pharmacy problem. A veterinary-pharma distributor in the Netherlands has a specific regulatory constraint: any product with UDD status (Uitsluitend door Dierenarts, "only by veterinarian") must be confirmed by a registered pharmacist before it leaves the building. Wrong release, wrong product, wrong volume, and you have a visit from the IGJ.

So the agent had to do three things no off-the-shelf tool does:

  1. Read the email and identify which SKUs were being ordered, even when the customer wrote "10 doosjes van die antibiotica voor melkvee, zelfde als vorige keer".
  2. Cross-reference each SKU against the UDD-status flag in SAP.
  3. Hold the response in a pharmacist queue if any UDD product appeared, and let it through automatically if none did.

About 22% of inbound questions touched at least one UDD product. The remaining 78% could ship a confirmation in under a minute. That ratio is the entire business case.

The architecture in one paragraph

The agent sits between the mailbox and SAP, never inside either. Microsoft Graph watches the bestellingen@ mailbox via a webhook subscription. Each new message lands in a small Node service on the customer's existing Hetzner VPS. The service calls our agent runtime with the message body, the sender's customer ID (matched on email domain, then fuzzy-matched on display name), and the last 30 days of order history pulled via the SAP Service Layer. The agent returns a structured object: matched SKUs, quantities, whether any UDD products are involved, a draft reply, and a confidence score per line. If no UDD product is in the basket and confidence is above 0.85 on every match, the draft is sent. Otherwise it lands in a queue.

The SAP Service Layer is the unsung hero here. It is a REST interface SAP added in Business One 9.0, and most teams running B1 have never touched it. We did not need to write a single SAP add-on.

The actual call to verify a UDD flag on an item:

GET /b1s/v1/Items('123456')?$select=ItemCode,ItemName,U_UDD_STATUS,QuantityOnStock
Cookie: B1SESSION=...; ROUTEID=...
Prefer: odata.maxpagesize=1

The U_UDD_STATUS field was a user-defined field a former IT manager had added in 2018 for an internal report. It had been quietly maintained ever since. We checked: 4,113 active SKUs, every UDD-flagged one tagged correctly. The data was good. The agent just needed to ask the right question.

The pharmacist queue

The pharmacist queue is the part of this build I am proudest of, and the part that took the most rewriting.

Version one was a Slack channel. The pharmacist (one of two BIG-geregistreerd staff) would get a notification per draft, click into a small web view, approve or amend, and the agent would send the reply. It worked. It also drove the pharmacist insane. He was receiving 70+ notifications a day, breaking flow constantly, and the queue was always either empty (frustrating idle waits) or 40-deep (frustrating panic).

Version two moved the queue into a small in-house web app with two changes that mattered. First, batched review windows. Three times a day (09:00, 13:00, 16:00) the pharmacist gets one Slack notification with a link, and reviews everything that has accumulated. Same total work, one-twentieth the interruption cost. Second, default-approve with override. The agent now presents the SKU match, the UDD flag, the customer's prior order history with that SKU, and a pre-filled approval. The pharmacist's job is to look for the exception, not to type out the rule.

Median review time dropped from 4m 12s per item to 47s per item.

Takeaway

The bottleneck in a regulated workflow is rarely the regulator. It is the interruption pattern around the regulator. Batch the human, keep the agent live.

The audit trail

UDD orders need an audit trail that survives auditor scrutiny. Every draft, every approval, every send, with timestamps and reviewer identity. The Dutch inspectorate can request these going back five years.

We considered logging into SAP. We did not. SAP Business One is not a logging database, and our brief was to leave it alone. Instead we wrote to a small Postgres instance with append-only constraints: no UPDATE permission on the audit_events table, no DELETE permission, period.

That is its own kind of opinion. There was a piece on Hacker News last week that argued the only scalable delete in Postgres is DROP TABLE, and for an audit log that is exactly the point. You do not delete events. You retire whole partitions on a rolling five-year window and drop the oldest.

CREATE TABLE audit_events_2026 (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  occurred_at timestamptz NOT NULL DEFAULT now(),
  event_type  text NOT NULL,
  actor       text NOT NULL,
  sap_item    text,
  udd_flag    boolean,
  payload     jsonb NOT NULL
) PARTITION BY RANGE (occurred_at);

REVOKE UPDATE, DELETE ON audit_events_2026 FROM agent_writer;
GRANT  INSERT, SELECT ON audit_events_2026 TO agent_writer;

Cheap, boring, auditable. The inspector gets a CSV. The agent never gets the ability to rewrite history.

First-month failures

Three things broke in the first month. They are worth naming because they will break for anyone doing this.

The fuzzy customer match. Customers email from gmail addresses they have used for fifteen years. The agent had to match free-text "Praktijk Van der Heijden" against an SAP customer card called "DIERENART HEIJDEN BV". We tuned it to ask the customer-service lead for confirmation on the first unknown match per address, then cache it forever. Threshold for auto-match: cosine similarity above 0.92 on a Dutch name embedding, AND a postal-code match in the signature block. Both, not either.

Stock-out replies. When a customer asked for 10 boxes of a product with 6 in stock, the early agent wrote a confident "we will ship 10 boxes tomorrow" reply. We added a hard rule: the agent must read live QuantityOnStock from the Service Layer for every confirmed line, and if requested exceeds available, the draft routes to a human regardless of UDD status. Obvious in hindsight. Embarrassing on day three.

The DocuWare attachment loop. Customers often replied to old invoices archived in DocuWare, which embeds a 3MB PDF in the reply. The Microsoft Graph webhook fired, we round-tripped the PDF through the agent context, billed an embarrassing number of tokens, and got nothing useful back. Fix: strip attachments larger than 256KB before the agent ever sees them, and re-attach only if the agent explicitly asks for them by filename.

Warning

If your agent reads attachments by default, your token bill will get worse before your inbox does. Strip first, ask second.

Numbers, six months in

The customer-service lead now starts her morning at 08:45 instead of 07:30. The inbox is at zero by 10:00. The four-person team has been redeployed: two now handle proactive customer outreach (a thing the company has wanted to do for three years), two stayed on inbox supervision and the pharmacist queue.

From the last full month:

  • 7,219 inbound messages processed
  • 5,634 (78%) auto-responded within 90 seconds
  • 1,585 (22%) routed to pharmacist queue
  • 1,571 of those approved without amendment
  • 14 amended (mostly volume corrections)
  • 0 incorrect UDD releases

Zero is the number that matters. The IGJ has not visited. We hope it stays that way.

Lessons from the rebuild

We would build the pharmacist queue first, not last. We sank two weeks into the auto-response pipeline thinking it was the hard part. The pharmacist queue is the actual product. The auto-response is a side effect of having clean SKU matching.

We would also push back harder on the "do not touch SAP" line. The customer was right that a SAP project was off the table, but adding three user-defined fields to the customer master (preferred-veterinarian, default-shipping-window, last-known-BIG-number) would have eliminated half our middleware logic. We treated SAP as immutable; it was just inconvenient.

When we built the email automation for the Nijmegen distributor, the thing we kept being surprised by was how much of the work lived in shaping the human-in-the-loop, not in the agent. That is where we would put the first two weeks of any rebuild like this one.

What you can do this week

If you run a shared mailbox in front of a legacy ERP, the smallest useful audit is this. Pull a random 100 messages from the last seven days. Classify each one by hand into three buckets: (a) could be answered by reading the ERP, (b) needs a human judgement call, (c) is the start of a sales conversation. The (a) percentage is your ceiling for what an email agent can take off your team. If (a) is above 60%, the build pays for itself before Q4.

Key takeaway

The pharmacist queue is the product, not the auto-responder. Build the regulated path first and the easy path falls out for free.

FAQ

Did this require changes to SAP Business One?

No. We used the SAP Service Layer REST API to read items, stock and customer cards. No SAP add-ons, no schema changes. The middleware ran outside SAP on the customer's existing VPS.

How does the agent decide a product is UDD?

It reads the U_UDD_STATUS user-defined field on each matched item via the SAP Service Layer. If any line in a draft touches a UDD product, the draft routes to the pharmacist queue instead of sending.

What about replies the agent gets wrong?

Two safety nets. A confidence threshold of 0.85 per SKU match (anything below routes to a human), and a hard rule that requested quantity above available stock always routes to a human, regardless of UDD status.

Could a smaller distributor use the same pattern?

Yes. The pattern (mailbox webhook plus structured agent output plus a queue for the regulated subset) works for any inbox sitting in front of a system of record. The audit-log piece matters most in regulated industries.

ai agentsemail automationcase studyintegrationsworkflowoperations

Building something?

Start a project