← Blog

Process automation

Process automation playbook: a four-eyes gate for STL prints

A Den Bosch implant clinic was hand-routing 1,940 Vectra scans a week through an 11-year-old Exquise EPD. The playbook for automating it without removing the human gate.

Jacob Molkenboer· Founder · A Brand New Company· 25 Sept 2025· 11 min
Brass relay switch beside folded carbon-copy form with chartreuse tag, wooden rubber stamp on ink pad, ivory paper surface.

Friday afternoon in Den Bosch. The senior tandtechnicus opens the SCANS_NIEUW folder on the file server and sees 387 unread STL files. Some are from Monday. He has three hours before the printers shut down for the weekend, and he still has to decide which scans go to which of the four Formlabs printers, which ones need a second opinion because the implant is north of €1,800, and which ones he cannot find a matching patient record for in Exquise. He stops answering Teams messages around 14:30.

This is the playbook we used to automate that workflow for a 29-person dental implant clinic group across three locations in North Brabant, while keeping the human gate exactly where it mattered.

The starting state

The group ran three locations, 29 staff, four Formlabs Form 3B printers, and one 11-year-old Exquise EPD that the previous IT supplier had configured in 2014 and not touched since. Vectra DT intraoral scanners in each chair wrote scans into a shared folder. A rotating duty tandtechnicus picked them up, looked the patient up in Exquise, decided the implant type, decided which printer to send to, and renamed the file to match the printer's expected pattern.

Friday throughput was 387 files. Median lag from scan to printer was 71 hours. The group was losing roughly seven cases a month to wrong-printer assignment or missing patient records. One of those cost €1,640 in scrapped materials. Two cost goodwill, which is harder to put a number on.

Walk the swim lanes before you write a line of code

Before we touched anything, we spent two days in the back office with a stopwatch. Not metaphorically. A real stopwatch on a bench. Every handoff got timed. We sat next to the duty tandtechnicus from 08:00 to 17:00 on a Tuesday and again on a Friday, and we wrote down every click.

The result was 11 handoffs between scan-on-disk and STL-in-printer-queue. Four of those handoffs were the same shape: open this PDF, find the case number, paste into Exquise, copy the patient name back, rename the file. That is 22 minutes per case at the median, and it is the same person doing the same thing four times per case. The diagram on the whiteboard was ugly. We left it ugly. Pretty diagrams hide handoffs.

What surprised us most was the implicit triage. The duty tandtechnicus already had a mental model of which printer suited which job (resin viscosity per implant family, last calibration date per machine, who was due to clean which tank that evening), and that model lived only in his head. We extracted it over three coffees, drew it as a table on the wall, and turned it into the routing rules. Without that step, the automation would have been correct on paper and wrong on the floor by the second week.

Reading Exquise without breaking the support contract

Exquise runs locally on a Windows server and stores its data in a Firebird database. The supplier sells an HL7 export module on a per-year licence, and offers no public API. We did not buy the module.

What we did instead: a read-only ODBC connection straight to the Firebird instance, with the RDB$READONLY role. Firebird supports read-only attachments natively, and as long as you never issue a write, the vendor's support contract treats your process the same as a reporting tool would be treated. The clinic's previous IT supplier had been doing the same thing for years to generate health-insurance reports. We confirmed this in writing before we shipped.

import fdb, os

conn = fdb.connect(
    dsn='exquise-srv01:C:/Exquise/Data/PATIENT.FDB',
    user='SYSDBA',
    password=os.environ['EXQUISE_RO_PASS'],
    role='RDB$READONLY',
    charset='WIN1252',
)
cur = conn.cursor()
cur.execute("""
    SELECT p.patient_id, p.bsn, p.last_name, p.first_name,
           t.treatment_id, t.tooth_number, t.implant_code, t.price_eur
    FROM patient p
    JOIN treatment t ON t.patient_id = p.patient_id
    WHERE t.created_at > ?
      AND t.status = 'OPEN'
""", (since,))
Warning

Never write to the Exquise database from outside the official client. Vendor support contracts treat any write from a third-party process as grounds to void the agreement. Read-only is fine, anything else is not worth the savings.

Matching scans to patients

The Vectra DT writes scans with a filename that contains the chair number and a timestamp. No patient ID. The link between scan and patient lives in the chair's appointment software, which is also Exquise.

We did not try to do face recognition on the scan. We did not try to OCR the metadata. We did the boring thing: we matched on the appointment slot. A scan written at 14:23 in chair 4 belongs to whoever was booked into chair 4 at 14:15 on that date. If no appointment matches within ten minutes either side, the scan goes into an unmatched queue and a human looks at it. That happens about twice a week, and the cause is almost always a chair swap that nobody wrote down.

Reach for the boring solution first. We spent a week trying to read scan metadata and chair-vendor APIs before we did the obvious thing. Lesson learned, again.

The four-eyes queue above €1,800

This is the part the clinic owner cared about most. Implants north of €1,800 in materials are usually complex cases: angled abutments, sinus lifts, full-arch jobs. Mistakes are expensive in money and worse in reputation. The owner wanted no STL to ever reach a printer for these cases without two distinct tandtechnici signing off.

The state machine is small:

scan_received
  -> matched_to_patient (auto)
      -> if price_eur < 1800: queued_for_print
      -> if price_eur >= 1800: pending_first_review
          -> first_review_signed (by tandtechnicus A)
              -> pending_second_review
                  -> second_review_signed (by B, B != A)
                      -> queued_for_print

Three rules, no exceptions:

  • The two reviewers must be distinct Active Directory identities. Not display names. AD object GUIDs.
  • The reviewer cannot be the same person who set the price in Exquise. We pull the editor from the Exquise audit table and block that GUID from either review slot.
  • Both signatures are timestamped and immutable. We append, we do not update.

We considered building a custom review UI. We did not. The group already had Microsoft Teams on every workstation. Each pending case posts an adaptive card to a private Teams channel for the tandtechnici, with the patient initials, tooth number, implant code, price, and a link to the rendered STL preview. They click Approve or Send back, and the bot writes the signature back to our database. Two weeks to ship, instead of two months. The tandtechnici do not need a third front-end to learn.

We were worried this would become rubber-stamping. It has not, so far. We monitor the median review time per reviewer per week. Anyone whose median drops below 30 seconds gets a quiet conversation with the lead. The threshold was set after we watched a tandtechnicus do a real second review and timed her.

Dropping the STL into the printer queue

The Formlabs printers watch a folder on the shared drive through PreForm. Drop a correctly-named STL into it, and the printer's software picks it up at the next poll. Simple, and resilient when you do two extra things.

Idempotency. The filename encodes case ID and revision. If a file with that name already exists in the printer folder or the archive, we do not write it again. This caught one case in week three where a network blip caused our agent to retry a drop. Without the check, we would have printed twice and billed the patient once.

Backpressure. If a printer queue has more than 12 pending jobs, we hold and route to a different printer. If all four are full, we hold and notify on Teams. We do not silently queue up to infinity behind a slow printer.

def drop_to_printer(case, printer):
    target = printer.folder / case.printable_filename()
    if target.exists() or printer.archive_has(case.printable_filename()):
        log.info('already dropped, skipping', case=case.id)
        return
    if printer.pending_count() > 12:
        raise PrinterFull(printer.name)
    tmp = target.with_suffix('.stl.tmp')
    tmp.write_bytes(case.stl_bytes())
    tmp.rename(target)
    audit.record(case, 'dropped_to_printer', printer=printer.name)

The rename trick at the end is there so PreForm never sees a partial file. Watched folders on a shared drive can pick up half-written files and fail in confusing ways. Write to a temp name, then atomic rename.

The audit trail you will be glad you built

Every state change writes an append-only row: case ID, from-state, to-state, actor (system or AD user), timestamp, SHA-256 of the payload before and after. Six weeks after go-live the group's quality manager asked us to reconstruct everything that had happened to case 2026-04-1881. It took 90 seconds.

The schema is three tables: cases, case_events, reviewers. case_events is partitioned by month for query speed. We resisted the urge to add foreign keys back into the live operational data. The audit log lives on its own Postgres instance with no joins into the main system, so if the application database has a bad day, the audit still reads. The trade-off is that we duplicate some patient metadata at the moment of each event, which is the right trade-off for a regulated workflow.

The group operates under oversight from the KNMT and the IGJ inspectorate, and any of their reviewers can ask for traceability at any time. An append-only log of who approved what, when, signed by AD identity, is the cheapest insurance you can buy. We store it in Postgres with a daily logical backup off-site. Nothing exotic.

What we measured six weeks in

Numbers from the first six full weeks after switchover, against the median of the eight weeks before:

  • Median scan-to-printer lag: from 71 hours to 9 hours.
  • Cases caught by the four-eyes gate that would have shipped wrong: 3.
  • Exquise support tickets opened by the clinic about our integration: 0.
  • Junior tandtechnicus FTE freed from routing work: 1.0. She moved to a CAD modelling role she had been asking for.
  • Friday 17:00 STL backlog: usually empty, occasionally 6 to 10 cases. The worst Friday before was 387.
Takeaway

When you automate a regulated workflow, do not remove the human gate. Move it to where it is cheapest for the human and most expensive to skip.

What we would do differently

Three things.

We built the price gate against the price written in Exquise at the moment the scan arrived. In two cases the price was edited upward after the scan was received but before approval, and the case slipped through the under-€1,800 path. The right behaviour is to re-check the price at the moment a tandtechnicus opens the review, not the moment the scan lands. We patched that in week five and backfilled.

We also under-invested in the unmatched queue in week one. We assumed it would be empty. It was not. It needed its own small UI and a daily reminder, because nobody was watching it. Build the exception handler with the same care as the happy path.

Third, we wired up Teams notifications before we sorted out who actually owned each alert. For two weeks every pending review pinged the whole channel, and the senior tandtechnicus spent his mornings muting his own laptop. We fixed it with per-reviewer mute windows tied to the on-duty rota in Exquise, but if we did this again we would build the rota wiring first and the notifications second. Notifications without an owner are noise.

Closing

When we built this for the Den Bosch group, the thing we kept running into was that the official path (HL7 module, vendor consulting, a custom review UI) was both more expensive and more fragile than the boring path (read-only ODBC, Teams adaptive cards, watched folders, append-only audit). The patient-facing change was zero. The change inside the back office was large. That is usually the shape of useful process automation.

If you have a similar workflow on your desk, the smallest thing you can do today is open a stopwatch and sit next to whoever is currently routing the work, for one full shift. Two days of timing every handoff will tell you which automation is worth building and which one is theatre.

Key takeaway

When you automate a regulated workflow, do not remove the human gate. Move it to where it is cheapest to enforce and most expensive to skip.

FAQ

Can you really read from the Exquise database without voiding vendor support?

Yes, with a read-only ODBC connection using the RDB$READONLY Firebird role. Confirm it in writing with the supplier first. Any write from a third-party process is a different conversation and usually voids support.

Why not buy the official HL7 export module?

It is a recurring per-year licence, it only exports a subset of fields, and it adds a second moving part to keep upgraded. For a read pattern, a direct read-only connection is cheaper and more stable.

How do you stop the four-eyes review from becoming rubber-stamping?

Track median review time per reviewer per week. If anyone drops below 30 seconds, the lead has a conversation with them. Threshold was set by timing a real review, not by guessing.

What if Exquise releases an update that changes the schema?

We pin the schema version in our reader and alert on mismatch. Updates land in a staging environment first. In two years of running similar integrations we have seen one breaking column rename, caught in staging.

Why drop STL files into a folder instead of using a print API?

The Formlabs PreForm watched-folder pattern is documented, supported, and survives software updates better than any unofficial API. Boring beats clever for production printer queues.

process automationautomationintegrationsworkflowarchitecturecase study

Building something?

Start a project