Product & Engineering

Interactions: every conversation in one record

How we brought calls, texts, emails, and in-app messages onto the clinician’s record, including the work of matching each contact to a person.

Thaynã
On this page

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 brought those conversations onto the clinician’s record. Matching each message or call to the right person turned out to be the harder part.

Four inboxes, no record

Four columns describing the interaction pipeline. Sources on the left: calls, SMS, and email plus in-app, each vendor sending its own shape. Ingest: connectors pull payloads, persist the raw event, stamp the timestamp. Resolve: processors normalize fields, resolve identity, emit one event. Surfaces on the right: interaction events flow to the clinician record, to the Manage App audit surface, and to downstream agents. Caption: every channel, one 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.

We wanted each call, text, email, and in-app message to appear as an event on the clinician’s record, in one chronological view.

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

Four columns describing identity resolution. Input: a raw signal, a phone number or email plus a timestamp, no person yet. Lookup: a temporal match resolves against the clinician at that point in time, history-aware. Branch: three outcomes, matched cleanly, partial match queued, or no match queued. Resolved: an interaction event with channel details plus resolved clinician ID. A footer feedback band shows the unidentified queue going to human review and resolving back into the stream. Caption: a phone number is not a person.

Capturing an event doesn’t tell us for certain who was involved.

The vendors supply webhooks. We still have to turn a contact value and timestamp into the right clinician record, accounting for changed phone numbers, 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 details became useful as we built this.

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.

Unmatched events need a place to go. An unknown number, an email sent by mistake, or a number shared by two clinicians lands in an Unidentified Contacts queue. An operations teammate can inspect the context and link the event to a clinician. The system saves that resolution for later lookups.

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

The shared record supports a few kinds of work.

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.