A Trusted teammate is about to call a clinician about a contract on the West Coast. Before they dial, they want to know what’s already been said. Two weeks ago, the licensing team emailed about a missing document. Last week, an SMS came back in reply to a shift suggestion. The day before that, an in-app message asked a question about pay. A voicemail in between went unreturned.
For years, all of that lived in four different places. The call was in one vendor’s console. The email was in a Front inbox. The SMS was in a different Front inbox. The in-app message was somewhere else entirely. None of it was on the clinician’s record in the platform. The only path to context was asking around, or walking into the call cold and hoping.
We built a system to put all of it on the record. The part that mattered most wasn’t the ingestion pipeline.
Four inboxes, no record
A clinician who works with us touches several systems on her way through the funnel. Voice calls go through one integration partner (Aircall, in our case). SMS rides on Twilio. Email and in-app threads go through Front. Each is a fine product on its own. None of them know about the others, and none of them know about the clinician record in our platform.
The consequences accumulate. A teammate who picks up a case cold has no idea what was said the last time anyone spoke with this clinician, and which channel it happened on. Anyone trying to ask “how many touchpoints does it take from first contact to an activated profile?” cannot answer the question, because the touchpoints are not in one place. Coaching the team on communication patterns is stuck too: there are no patterns to look at, only four separate piles of artifacts that happen to share a phone number.
The model that fixes this is simple to say and tricky to build. Every call, every SMS, every email, every in-app message between Trusted and a clinician should be a single event on that clinician’s record. One table. One view. Every conversation, one record.
The pipeline that captures it
The capture side has four moving parts.
Connectors sit at the edge, one per integration partner. A connector receives webhooks from the upstream service---a completed call, an inbound SMS, a new email message---and writes a raw IncomingEvent record. This is the only place that knows the shape of the vendor’s payload. The rest of the system speaks our schema.
Processors read raw events and produce structured InteractionEvent rows. There is one processor per channel. Each one knows how to parse its channel’s payload, resolve the participants, and produce a normalized event with channel-specific detail attached.
Channel detail models hold the parts that don’t generalize. A call has a duration and a recording URL. An SMS has a body. An email has a subject and a thread. An in-app message has a sender role. We keep these on their own polymorphic detail records rather than smearing every field onto the main interaction row:
class InteractionEvent < ApplicationRecord
belongs_to :clinician
belongs_to :employee, optional: true
belongs_to :details, polymorphic: true
end
class CallEventDetails < ApplicationRecord; end
class SmsEventDetails < ApplicationRecord; end
class EmailEventDetails < ApplicationRecord; end
class MessageEventDetails < ApplicationRecord; end
The shared columns---who, when, which clinician, which employee, which direction---are the same across every channel. The channel-specific columns live one join away. Adding a new channel later means a new connector, a new processor, and a new details model. Nothing else has to change.
The status lifecycle on the raw IncomingEvent is where most of the operational nuance lives. An event is pending when it arrives. The processor moves it to one of a small set of terminal states:
pending
├── success
├── unmatchable_clinician
├── unmatchable_employee
├── unmatchable_both
├── skipped
└── ignored
success is the happy path: both ends of the conversation resolved and an InteractionEvent was written. The three unmatchable_* states are where the operational work lives. They mean the capture worked but identity resolution did not, and the event is parked until a human can resolve it. skipped and ignored cover events the system shouldn’t bother trying: internal test numbers, automated platform notifications, traffic that is clearly out of scope.
That state machine is the part of the design I revise the least. Every interesting failure mode in this system maps to one of those states.
A phone number is not a person
The capture pipeline is the easy half. The reason most attempts at this problem stall is the other half: figuring out who you were actually talking to.
Capture is a webhook plumbing problem, well-understood and largely solved by the vendors. The work the rest of the platform depends on is identity resolution: turning a phone number and a timestamp into a clinician record, reliably, over years of changed phone numbers and ported lines and shared inboxes.
Email is almost easy. We compare case-insensitive against the clinician’s recorded address. The edges are real but bounded---she changed her address last year, she uses an Apple Hide My Email alias, the address is shared with a partner---and most events resolve cleanly.
Phone numbers are not easy. Different vendors format the same number differently. The number on file might be the personal number she gave us in onboarding, the work number she texts from on Tuesdays, or a Google Voice forward she added without telling anyone. Numbers get reassigned and ported. Two clinicians can share a household landline.
I started with a few invariants. Every phone number is normalized to a single canonical format on ingest, so the database never has to reason about formatting differences. Contact records on a clinician are kept as a list with timestamps, not a single mutable field, so changes are additive rather than destructive. And the lookup logic is temporal: given a contact value and a timestamp, find the clinician who owned that contact at that time, not the clinician who owns it today.
That last point is what separates this from a naive join. A clinician who called us in March from a number she later gave back to the carrier should still resolve to the right person when we look at the March event in June. The lookup is parameterized on both the contact and the event time, and it walks the contact history rather than the current row.
Two things fall out of this design that I didn’t appreciate up front.
One: the lookup is the same shape across channels. Email lookup, phone lookup, in-app sender lookup. Each one takes a contact value and a timestamp and returns a clinician or a reason it couldn’t find one. The processors stay simple because the resolver is shared.
Two: the “could not find one” branch is where the product lives. There are always events we cannot match. The number is unknown. The email belongs to a recruiter at another agency who emailed us by mistake. Two clinicians could plausibly own the same legacy number. These events don’t vanish. They land in an Unidentified Contacts queue inside our internal ops surface, where a human can look at the event, see the surrounding context, and link it to the right clinician record. Once linked, the system records the resolution, and the same contact pattern resolves automatically next time.
The matching algorithm gets the common cases. The audit surface catches everything else, and is the reason we can promise that every interaction ends up on a record---not just the ones the algorithm gets right.
What unifying the record lets us do
Once every conversation is a row on a clinician’s record, a few things become possible that weren’t before.
Situational awareness before a call. A teammate who used to walk into a call cold now has a chronological view of every prior touch: which channel, which Trusted employee, when, and a short summary of what was discussed. The history a manager sees in retrospect is the same history; the history a downstream model would see if we ask it to suggest the next best touch is the same history.
Funnel analytics that work end to end. “How many touchpoints, on average, between a clinician’s first reply and her first submitted application?” is a question that’s now answerable. The data is in one place. Anyone can ask---ops, product, leadership---without re-stitching the four vendors’ exports by hand.
A signal layer for the matching system. Behavior is a better predictor than a survey. A clinician who consistently engages on SMS but ignores email is a different segment than one who responds to neither. A clinician who replies quickly to shift suggestions but slowly to compensation discussions is teaching us something about her preferences without ever filling out a form. The interactions table is the raw material for a clinician preference model built from how she actually communicates, not from what she once told us she preferred. We’re early on this. The foundation is in place.
--- Thaynã, Engineering