Applied AI · field sales · built solo
The notes a salesperson types after a shop visit are all the onboarding team gets, and the one question that decides the go-live date is usually the one nobody asked. This tool makes that question visible. Two minutes to read, the full write-up is one click away.
Field discovery is a handoff problem before it is an AI problem. A rep finishes a good conversation, writes four lines into the CRM that afternoon, and three weeks later an onboarding consultant opens the record and finds out that the one requirement that decides the go-live date was never asked about. This is a tool that closes that gap, and this page is the reasoning behind how it is built.
Everything below runs. The merchant data is synthetic, written to carry the pattern rather than borrowed from anyone, and the Salesforce writes were wired against a real Developer Edition org.
00 · In one picture
Same visit, same hardware store. Left is what a rep has time to type. Right is what the tool makes from the rep talking for a minute in the car, and the rows at the bottom are the questions nobody asked, which a notes field can never show you.
Same transcript, same merchant. The left is the CRM's free-text field, which accepts four lines and forty and cannot distinguish silence from a resolved requirement. The right is coverage against the vertical's card stack, with three confidence states and the never-discussed cards printed rather than omitted.
Today · the CRM notes field
Every line here is true and the record looks complete. Nothing on it says the rental counter runs on a second system, or that four questions were never asked.
With the tool · what onboarding opens
✓ the merchant said it, and the quote is attached · ~ the system thinks so but a person has to agree · ! the question was never asked
Talk, paste or type, after the conversation, never during it.
Voice, paste or typed text all enter at one point and take the same path. Transcription is bought, not built. Entries are append-only and current state is derived across all of them.
The AI checks the words against a fixed list of questions for this kind of shop. It cannot add questions of its own.
One LLM call receives the vertical's card ids as a closed enum and returns a status per card. A local keyword pre-pass runs first and instantly, capped at inferred. The model's response is validated against the contract and rejected on failure.
Rental counter on a second system
confirm before onboarding
The phone shows only what is missing. The red card is the one that could sink the deal. One tap confirms a guess.
Progressive disclosure, confidence by weight. Viability at 27px on red, awaiting-confirm at 18px on near-black, never-discussed at 15px on stone. Inferred never auto-writes. Every open gap carries an owner.
Plain fields on the Salesforce record onboarding already uses, with the merchant's words under every answer and the open questions under their own heading.
REST upsert on an external id, six plain custom fields, raw captures attached as ContentNotes, a Chatter receipt, and one review Task on first sync. No managed package, no Lightning component.
01 · The problem
A rep writes down who decides, when they want to switch, what they can spend. What never gets written down is the requirement only this kind of shop has, because nobody knew to ask. A hardware store with a rental counter runs two systems. A grocery with a butcher counter prices by weight at the scale. A shop taking food-assistance benefits has a six-week certification ahead of it whether anyone mentioned it or not.
An empty field and a settled question look identical in a CRM, and the whole tool exists to refuse to let them.
A rep running a retail territory has a good discovery conversation and captures it honestly. They write down the decision maker, the timeline, the budget, the reason the merchant is looking. What they do not write down is the requirement that only this kind of merchant has, because it did not come up, and it did not come up because nobody asked. A hardware store with a rental counter is running two systems rather than one. A grocery with a butcher counter prices by weight at the scale. A specialty shop taking EBT is looking at a certification window that runs six to eight weeks whether or not anyone mentioned it.
None of that is a note-taking failure. It is a coverage failure, and the reason it survives is that the CRM has no opinion about what a complete discovery looks like for this merchant. A free-text field accepts four lines and accepts forty, and it accepts silence on the one card that matters.
Silence is the failure mode. An empty field and a resolved requirement look identical in a CRM, and the entire design follows from refusing to let them.
So the tool's job is narrow. It reads what the rep actually said, maps it onto a checklist that the vertical defines, and is explicit about the difference between something the merchant confirmed, something the system inferred, and something that never came up at all. The last category is the product.
02 · How it works
A rep with one bar of signal needs an answer right now, so everything that has to be right runs on the phone with no network, and the AI does exactly one job.
The first decision was which parts of this are allowed to be probabilistic. A rep standing in a parking lot with one bar of signal needs an answer immediately, and a requirements checklist that only works when the network does is a checklist that gets skipped. So the work splits along a line drawn by what has to be right rather than by what is easy to build.
On the phone · instant, works offline
In the cloud · the AI's one job
Tier one · deterministic
The requirements library, alias matching on incumbent systems, conflict detection, the pre-call brief, and the handoff artifact all live here, and none of them need a network, a model, or a wait.
epi core reaches Epicor EagleinferredTier two · probabilistic
One LLM call through a broker, extracting requirement coverage from the raw capture. Its output is validated against the contract before anything touches state.
confirmed outranks inferredThe list of questions is the real asset. Every kind of shop has its own set of cards in one file, and adding a new kind of shop means editing that file from an admin screen, no engineer needed.
The keyword pre-pass is the part I would defend hardest. It is deliberately weaker than the model and the ceiling is enforced in code rather than trusted: it can only ever produce inferred, and it can never produce not_needed. When it sees "we don't rent equipment anymore" it declines to flag the rental card, and it still refuses to rule the card out, because that call needs a reader and a keyword sitting near a negation word is not a merchant statement. A rep in a dead zone gets a real checklist in under a second, and every line on it is honestly labelled as a guess until the reader has run.
Everything keys off one JSON file. Each vertical carries its own cards, each card has a type, a blocking flag, a support status, the question a rep would actually ask, the keyword signals for the pre-pass, and a note explaining why it exists. Adding a vertical is editing that file, and the admin screen in the app edits it in place. Nothing about that path needs engineering, which matters because verticals change quarterly and anything that needs a developer to stay current goes stale inside one.
The screens
The rep gets a phone that shows only what is missing. The onboarding consultant gets a desk view with every answer, its quote, and the recording next to it, so they can check the tool's work.
Three screens matter: capture, the gap panel, and the handoff artifact. Confidence reads by weight rather than border style, because dashed borders were the first thing to vanish in sunlight. Required action sits in the bottom third, where a thumb can reach it on a large phone.
Kestrel Hardware & Rental
What’s missing
4h ago
Rental counter runs on a second system
“they run that on a point a rental thing so that’d need to tie in”
Two systems to tie together. Confirm this can be done before the deal moves.
integration · unverified · go-live +3 wks
Decision maker
“Teddy? Ted?” · authority unclear
Never came up: budget, timeline, restricted chemical licence, loyalty programme.
Three lanes and multi-register reporting are a strong fit. Say so.
✓The one thing that decides whether the deal should have closed, in the merchant's own words, with what it does to the date.
Viability, 27px on red, with the verbatim evidence and the expected go-live shift. The five-second test: show the panel, take it away, ask what mattered most. If the answer is not this card, the weight is wrong.
The system thinks the manager decides but nobody confirmed it, so it stays a guess until the rep taps.
Inferred, 18px on near-black. Inferred never auto-writes. The one-tap confirm is the human step that the eval numbers below argue for, since the schema constrains vocabulary rather than judgment.
One quiet sentence, carried over as the questions for the next visit.
Not-discussed, 15px on stone. Only gaps are shown, never the full checklist. These become the pre-call brief for the next visit, and a confirmed card is never re-asked.
If the shop is a strong fit somewhere, the rep hears that too.
Platform strengths collapse into one green bar. Three full-size cards pushed the decisions off the screen.
Kestrel Hardware & Rental
Opportunity 006Ax0001 · 1 capture · voice · 4h ago
The desk view. Every answer links back to the words it came from. The last sentence, after the tacos, is the one that moves the date.
The handoff artifact on the desk surface. Extraction rows with evidence, the unmapped phrase preserved verbatim, and the transcript with confirmed and viability spans marked. The buried lede is the golden-set fixture this deal exists to carry.
03 · The argument
Ask an AI to summarise a recording and it comes back with different categories every time, so nothing downstream can count on it. Here the AI is handed the nineteen question cards for this kind of shop and can only mark each one confirmed, guessed, or never came up. Anything the merchant said that has no card is kept aside in their own words, and that pile is how an admin learns a new card is needed.
With the deck · 19 cards for a hardware store with rentals · Kestrel's read
could sink the deal, confirmed confirmed guessed never came up
Without it · what one run invents, none of it twice
Five made-up categories per run, 99 across twenty runs. To a database those are ten things. To a person they are four.
The obvious way to build this is to hand the model a transcript and ask for a summary of what was covered. That demos beautifully, and it also produces a different set of categories every time it runs, which means nothing downstream can count on anything: you cannot report on coverage, you cannot compare two visits to the same merchant, and you cannot build a queue of open gaps, because "age check at register" and "ID verification" and "age_verification" are three different things to a database and one thing to a person.
So the model does not get to name the categories. It receives the exact card ids for this vertical as a closed enum and its only job is to decide, for each one, whether the merchant confirmed it, whether it can be inferred, or whether it never came up. Anything the transcript contains that has no card goes to a separate unmapped list with the raw phrase preserved.
Forcing a real thing into the wrong card is worse than admitting there is no card for it, because the first one is silent and the second one is a backlog item.
That last rule is the one I care about. In the golden set there is a merchant who mentions WIC, and the tool has an EBT card. Folding WIC into EBT would look like a successful extraction and would be a data-integrity failure, because they are distinct programmes with distinct requirements and the record would now assert something nobody said. The correct output is EBT confirmed, WIC unmapped, and a vocabulary gap that an admin sees and can turn into a new card.
confirmed, inferred, or literal null. A percentage is rejected, because 0.8 is a number that invites a threshold and a threshold is a decision nobody made deliberately.not_needed requires an explicit merchant statement, quoted. Silence produces not_discussed, forever.unmapped with the phrase intact.Every one of those is enforced by a validator that runs on the response before it reaches state, and a response that violates one is rejected and retried rather than repaired. Repairing it would hide how often the model needs repairing, which is exactly the number I want to be able to see.
04 · Testing it
Eight recordings, each built to carry one way this goes wrong, and I wrote the correct answer for each by hand before the AI ever ran. Then the same recording twenty times with the deck of cards and twenty times without it.
The golden set is eight transcripts, and the expected output for each one was written by hand before any model run. That ordering is the whole point, because expectations written after you have seen the output are a description of the output. Each fixture carries a named failure mode: a buried lede where the blocking requirement arrives in the last sentence after a story about parking and tacos, a negation case, a near-empty capture, a contradiction across two visits, a secondhand report with an unresolvable pronoun, and a control case where nothing is wrong and the correct behaviour is to stay quiet.
The primary metric is recall on viability cards, targeted at about 100 percent, and the reasoning is asymmetric: a missed viability item blows up a go-live, and a false flag costs a rep fifteen seconds. So the system is tuned for recall and over-flagging is accepted.
To test whether the closed enum is doing real work, both arms run inside the same invocation on the same fixture, and the second arm has the enum stripped out of the prompt while everything else stays identical. Twenty runs per arm, all twenty on one fixture, so what is being measured is run-to-run variance rather than fixture-to-fixture difficulty.
Twenty runs per arm on one fixture, measured 2026-08-29. 99 invalid ids is roughly five per run against a vertical with nineteen real cards.
With the deck it invented nothing. Even so, only nine runs in twenty agreed exactly, which is why a person confirms every card before it reaches Salesforce. Every deal-breaker in the test set was caught, and each read costs about three cents and twenty seconds.
The denominator, because it is the first thing worth asking: 99 is the total count of invalid ids emitted across the twenty unconstrained runs, so roughly five per run, against a vertical that has nineteen real cards. Every one of those is a category that exists in exactly one run and nowhere else, which is precisely the failure that makes downstream reporting impossible. The constrained arm emitted zero.
The modal-set number is the honest half of the result. Even with the enum, only nine runs in twenty produced the same id set, which means the model's judgment about coverage genuinely varies and the schema constrains the vocabulary rather than the opinion. That is a real limit, and it is the argument for the human confirmation step rather than an argument against the design. The rep sees what was read and confirms or corrects it, and what reaches Salesforce is what a person signed off on.
The deterministic tier is graded exactly, not statistically, and it needs no API key: alias matches, alias precision decoys, conflict detection, and the seven contract rules, currently 22 checks. The precision decoys matter more than they look. Several vendor names are ordinary English, so the suite asserts that "we talked about the square footage of the back room" and "she runs a light speed operation" and "rock solid guy, been there thirty years" all match nothing, while the real mentions still match. A false incumbent is worse than a missed one, because it pre-flags requirements off a system the merchant never named.
The rest of the numbers, for completeness: viability recall 2 of 2, seven of eight fixtures passing, $0.0298 and 20.9 seconds mean per extraction.
05 · What lands in Salesforce
Nothing to install, nothing to learn. Sending twice updates the same record. The field that matters most is the one listing what never came up, and it is the one a tidy-up would delete first.
The handoff writes to the Opportunity through the REST API, and the fields are deliberately plain. They render as text the way any Salesforce field does, and there is no managed package, no Lightning component, and nothing an admin cannot read. An onboarding consultant opens the record they already use and the discovery is on it.
Open gaps
rental_contracts · Point of Rental integration, unverified+3 weeks against merchant expectation
Equipment rental contracts: trailers, lift, contract system in play “they rent equipment out the back, trailers and a scissor lift” Current POS: Epicor Eagle (retail) + Point of Rental “they're on an epi core system” Reason for switching: reporting, ~3% card fees “wants better reporting, hates the current payment processing fees” Hardware scope: 3 lanes (rep edited, was 2) “two lanes, he wants a third”
STILL NEEDS DISCOVERY Decision maker: Ted, manager. Authority unclear, rep to confirm with owner. NEVER CAME UP Restricted chemical licence check · Budget · Timeline · Loyalty programme
ContentNote · raw capture, voice, Tue Chatter · “Field discovery synced 4 confirmed, 1 open, 4 not discussed” Task · Review viability flag before kickoff · High
The record after one sync. The “rep edited” note is there because a correction by a person and a reading by the AI should never look the same.
The record after one sync. Provenance prints per line: model-read and rep-confirmed, rep-edited, rep-answered without a model read, and rep-marked still open, which moves the card out of Confirmed and into Open under its own heading.
| Field | What it carries, and why |
|---|---|
| Deal_Key__c | External id. Every sync is an upsert on this, so re-sending updates the record in place and never creates a duplicate. This is the field that makes the integration safe to run twice. |
| Discovery_Status__c | A picklist an admin can sort a list view on, so the queue is built from a saved view rather than from a new tool. |
| Discovery_Confirmed__c | Long text. One block per requirement: the label, the value, and the quote it came from. |
| Discovery_Open__c | What never came up, printed under its own heading. This field is the product and it is the one that would get deleted first by someone optimizing the page layout. |
| Viability_Flag__c | The one blocking requirement, above the fold, in the card's own id so it is greppable. |
| Expected_Golive_Shift__c | The implementation impact in weeks against what the merchant expects, which is the number that makes the flag actionable rather than informational. |
Alongside the fields, each sync attaches the raw captures as ContentNotes, posts a Chatter receipt naming what was written, and creates one review Task on first sync. The Chatter post is there so that the record shows who wrote what and when, without anyone having to trust the integration.
The version I demoed wrote every confirmed requirement identically whether the model read it or the rep typed it, and that turned out to be the most consequential thing I got wrong. The record has four genuinely different states and they now print differently: the model read it and the rep confirmed it, the rep changed the value from what the model read, the rep answered a card the model never got, and the rep says the card is still open. That last one moves the requirement out of the confirmed field and into the open field, prints under its own heading, and comes back on the next pre-call brief. Before that change, a rep flagging "I need to talk to the GM about this" was being recorded as a resolved requirement.
06 · What broke
All mine rather than the AI's, and most found by using the thing rather than testing it. The technical version has all six.
These are all mine rather than the model's, and most of them were found by using the thing rather than by testing it, which is its own lesson.
Restricted chemicals: Restricted chemicals. Now a card with no value prints as a heading with a quote under it.The confirm handler stored the card's label as its value when the model had not extracted one, so Salesforce received lines that read Restricted chemicals: Restricted chemicals, which looked enough like data that I did not catch it until I read a record properly. The fix was to store null explicitly and to dedupe the value against the label when rendering, so a card with no value prints as a label with a quote under it and nothing pretends to be an answer.
I edited a card during a run-through to say the owner was interested but that I had only spoken to the GM, and it posted as confirmed with no indication a human had touched it. A reader had no way to tell a correction from an extraction. The fix was the provenance model in the section above, which cost more design thought than code: the hard part was deciding that "still needs discovery" is a fourth state rather than a variant of the other three.
A small one, and worth listing because it is the kind of thing that destroys trust in a tool faster than a wrong number does. The edit sheet checked the wrong field for emptiness. The fix was the check, and then rewriting the message, because telling someone they did nothing when they did something is a bad default even when the check is right.
The renderer added a class for the combined phone-and-desktop view and removed only some of the alternatives, so once you clicked into it every later mode kept showing both surfaces. The fix was removing all four classes on every render, and then adding a regression check named "leaving Both really leaves it", because the bug was invisible until you went back.
I wrote a nicely typeset string into a WWW-Authenticate realm, and header values are ByteStrings, so the Response constructor refused it and the whole route failed. The fix was ASCII, and the reason I am listing it is that my own unit test caught it before it shipped, which is the entire argument for testing the boring parts.
Some vendor names are genuinely several words on the box, so I widened the matcher's scanning window from two tokens to three. That immediately produced a false match: a three-token window reaches a two-word vendor name by padding it with a short leading word, so "she runs a light speed operation" matched a POS vendor at a cost of one deletion, and the context gate that exists to catch exactly this never saw it because it was checking a string that no longer looked like the vendor name. The fix was to reject the shape before grading it, by matching a window against the vendor name's own word count, with one deliberate exception for a transcriber splitting a joined name in two. The precision decoy in the eval suite is what caught it, in the same run that the new name started matching.
What runs it
The language model that reads what the rep said. One call per capture, about three cents.
One extraction call per capture through the worker. Prompt is versioned; a prompt change is a code change.
Reads the wordsThe rulebook every AI answer is checked against before it can change anything.
Structural schema, then core/validate.js for the library-dependent rules. Reject and retry, never repair.
A small server that holds the passwords so the phone never has to.
Brokers the model and Salesforce. Origin check, shared-secret header, rate limit. Keys never reach the browser.
Holds the keysWrites six plain fields to the record onboarding already uses. Safe to send twice.
Client Credentials flow, External Client App, API pinned at v66.0, idempotent upsert on Deal_Key__c.
Lets the app work with no signal and send the capture later, with the pending count always visible.
App shell and library cached. Queue flushes on open, on reconnect, and on tap. No Background Sync, because Safari.
Works offlineOpens from the home screen like an app, with no app store and no IT enrolment, so fixes ship mid-pilot.
Web first to validate the motion. Native only if background audio, geofencing, or true push become requirements.
No app storeNo framework and no build step, and every question for every kind of shop lives in one editable file.
ES modules, core/ shared by browser, worker, and eval runner. 38 cards across 5 verticals in requirements-library.json.
08 · Where it came from
I built this for a final-round hiring case, a take-home with a time box of a few days and a few focused hours of building expected, followed by a live session where I walked a cross-functional panel through the build and took their questions. The brief described how field reps run discovery in person and summarise it into a single CRM field, gave a handful of sample deals where thin discovery had turned into slipped go-lives and reps being pulled back into onboarding, and asked for a point of view on the whole discovery-to-handoff motion, one piece of it built well enough that a real person could open and use it, what I chose not to build, build-versus-buy thinking, and how I would know it was working.
The role went to another candidate. The build and the thinking behind it are mine, so they live here, with the company, the sample deals and the case materials kept out on purpose.
This is a single-call extraction pipeline with a retry loop. It does not use tool calling, retrieval, MCP, or an agent loop, and the structure is enforced by my own validator in code. A second version, in progress, adds tool use with a human confirmation gate in front of every write to Salesforce, plus an eval layer for tool choice.
07 · Limits
Wired and verified: the custom fields, the idempotent upsert on the external id, the note attachments, the review Task, and the Chatter receipt, all against a Developer Edition org through the REST API. The deterministic tier, the extraction contract and its validator, the offline queue, and the eval suite all run.
Not wired: the list view sort, the path component, and the per-gap Task mapping are admin configuration rather than build, and I would set them up with whoever owns the org rather than shipping an opinion about someone else's page layout. Two-way sync is deliberately absent, because a field that writes in both directions needs a conflict story before it needs an implementation.
The published demo runs canned. There is no LLM broker wired into this deployment and no key anywhere in it, so the extraction you see in the walkthrough is a hand-written record of the quality the model produces on that transcript, and the app's other deals fall through to the deterministic keyword pass, which is exactly what a rep sees before the reader has run.
What I would build next is the measurement, and it is one outcome number with leading indicators underneath it. The outcome is the rate at which onboarding has to re-interview a merchant before kickoff, because that is the step that costs a merchant a month and a rep their credibility. The leading indicators are gap-closure rate between the first visit and the handoff, the share of handoffs that arrive with an open viability flag, and how often an unmapped phrase becomes a new card, because that last one tells you whether the library is keeping up with what reps are actually hearing.