Xonark

When a tool description became a regex: why we made our AI's tools fully typed

· Jack Jia · 6 min read

  • xonark
  • susuai
  • ai-native
  • tool-calling
  • build-log

When a tool description became a regex — a prose tool description was parsed as a validation pattern, so our AI phone agent kept opening requests instead of booking.

At Xonark we build Xona, an AI phone agent for dental clinics. It answers the call, talks to the patient, and books the appointment in the practice’s scheduling software. (In our repos it’s still called susuai — the engineering codename.)

For a while, on one configuration, it wouldn’t book. It would have a perfectly good conversation, collect the right details, and then — instead of writing a confirmed appointment — quietly open an appointment request for the front desk to handle later. No error. No crash. Just a system that kept choosing the safer-looking fallback over the thing the patient actually asked for.

This is the story of why, and why the fix was to stop describing our AI’s tools and start typing them.

How a function-calling tool used to be defined

Xona’s agent calls tools the way any modern LLM agent does: we hand the model a set of functions it can call — book_appointment, create_request, lookup_patient — each with a name, a description, and a schema for its arguments. The model picks one and fills in the arguments; we validate them and run the real Go function behind it.

Our first framework for this — we called it OAF, for “OpenAI Function” — registered those tools from a compact text DSL. Each parameter was three lines by position: a flag, a description, and an optional validation pattern. Line order was the meaning.

That positional design is the whole bug in miniature. A definition that was supposed to read as “here is a field, here is what it’s for” could, with one extra line, read as “here is a field, here is what it’s for, and here is a regex it must match” — without anyone deciding that.

The line that became a pattern

The notes field on our scheduling tool had a multi-line description — a couple of sentences telling the model what kind of note to write. In the positional DSL, the second line of that description landed exactly where a validation pattern was expected. So a sentence of English prose was compiled into a regular expression and enforced as a constraint on the field’s value.

Real note text never matched that accidental pattern. So validation rejected the booking arguments every time. And because the agent is built to degrade gracefully rather than fail loudly, it did the responsible-looking thing: it fell back to create_request and moved on. The patient got an outcome. It just wasn’t the booking.

The tell was the shape of the failure. It wasn’t random — it was every booking on that path, which is the signature of a constraint, not a flaky model. Once we read the generated schema instead of the DSL we’d written, the phantom regex was right there.

The fix: make the tool a typed contract, not prose

We rewrote the framework — OAFv2 — around a single idea: a tool’s input and output should be a typed Go contract, and the JSON schema the model sees should be generated from that type, not hand-assembled from positional text.

In v2 you register a tool like this:

RegisterTypedTool[BookArgs, BookResult](reg, "book_appointment", desc, handler)

BookArgs is a real Go struct. Its fields carry their constraints as struct tags — enum, pattern, min/max, required — and those tags are the only place a constraint can come from. A description is a description; a pattern is a pattern: tag. There is no longer a position in which prose can be mistaken for a rule, because there is no position at all. The compiler reads the type; the type produces the schema.

The principle we took away — now written down as a rule in the repo — is short: make illegal states impossible in the function schema, not in the description. If a field must be one of four values, that’s an enum, not a sentence asking the model nicely. The model is good at following schemas and bad at being lectured.

A few things fell out of that for free:

What we actually shipped

This wasn’t a parallel experiment we left running. We migrated every handler — dental, tire, and transport voice agents, the SMS agent, the shared scheduling primitives — onto v2, then deleted v1 outright. As of mid-June the old framework is gone from the codebase; there is no book_appointment left that builds its schema from positional text.

The honest lesson isn’t “we found a bug.” It’s that the bug was invisible at the layer we were working in. The DSL looked fine. The conversation looked fine. The failure only became legible when we stopped trusting the description we wrote and read the schema the machine generated from it. Typed tools collapse that gap: the thing you write and the thing the model sees are the same object, checked by the compiler before anything ever calls a model.

We expect to keep finding cases where a constraint belongs in the type and we’d put it in prose out of habit. That’s fine. The cost of catching them is now a compile error, not a patient who didn’t get booked.


This is part of our build-log series on how we ship and break things at Xonark. The first post — 31 days, 166 commits: why we built our own CRM — is the same spirit: a real production bug, fixed in the open.

If you’re here for the dental product rather than the engineering, start with the free Dental Leakage Scan.

← All posts