Solving a day job problem with AI
If you sell something people install and keep running, you eventually hit the same question: which of our customers are quietly drifting away? And the annoying part is that the answer exists. It’s just smeared across five systems that were never meant to talk to each other, and the one person who needs it has maybe two hours a day to go looking.
That’s the job Nexus was built for. It’s a self-hosted client-health tool for a small company with a few hundred customers. One operator, one screen, every customer, and a short list of who to call today. Here’s why I built it, where it stands a few months in, and what it beat into me along the way.
We had all the data. That was never the problem.
The company already had everything it needed. The licensing system knows which installs are active and which have gone quiet. Telemetry knows when each one last phoned home and what version it’s on. Billing knows who pays, who came in through a reseller, and whose renewal is next month. The CRM has every email, ticket, and call. The meeting recorder has a transcript of every customer conversation going back years.
None of it was connected. Want to know that a customer has been offline for a month, their renewal is in three weeks, and nobody’s emailed them since last quarter? Cool, that’s four browser tabs and a very good memory. Multiply that by a few hundred customers and it just doesn’t happen. Customer health lived in one person’s head, and a head is not a system.
So I kept the goal deliberately boring. Pull every source into one Postgres store every night, keep the origin of every single value, and spit out a ranked call list driven by plain rules. No mystery score you have to trust. No model deciding who matters. Just: this signal fired, here’s the raw record it came from, go look.
What’s actually built
Nexus went from an empty repo to live on an internal VM in about a month, and it’s run a nightly ingest and compute cycle every night since. Five source adapters pull licensing, telemetry, billing, CRM, and meetings. The first full backfill chewed through north of a million telemetry rows and every meeting transcript in the archive.

After ingest comes the compute pipeline: normalize, resolve identity across sources, figure out freshness for every license, detect signals, rank the call list, then check on itself. There are around twenty signal kinds now. Some are obvious (install offline, payment failed, renewal imminent). Others only exist because the operator asked for them (usage trending down against that client’s own baseline, no quarterly check-in logged, we owe someone a reply).

The dashboard is a Next.js app: a roster of real customers, a profile per client with a license and product matrix, the daily call list, a filter builder, an alerts queue with snooze and addressed states, and a “view source” link on every number that opens the raw record behind it. Health formulas and signal thresholds are editable right in the UI and stamped with a config hash, so you always know whether a stored signal was computed under the current rules or last week’s.
Stack is TypeScript end to end. Node for ingest and compute, Prisma over Postgres with pgvector, Next.js for the UI, systemd timers on the VM. A few hundred tests, a versioned requirements folder, and a running memory log so any session (human or agent) can pick up where the last one left off.
What I learned
Build against fake data first. Then budget real time for the real data. Everything was built mock-first behind a single adapter interface, so cutting over to live sources was a config swap. That part worked exactly like it was supposed to, which felt great for about a day. What the mock data couldn’t tell me was how messy identity gets. Fake customers have clean names and one ID each. Real customers have three spellings, a reseller’s email on their billing account, and a licensing system that happily reuses customer IDs after the old ones get deleted. Dozens of collisions turned up in a single sweep. The plumbing took a month. The identity cleanup is still going, and honestly that’s the right ratio.
Pick one ground truth and stop being clever. The first roster tried to infer who was a real customer by clustering evidence across all the sources. It was confident, it was wrong, and it quietly wrecked revenue attribution. The fix was dumb: an active subscription in billing means you’re a customer, full stop, and everything else hangs off that. Boring rule, clever rule, boring rule wins. The operator could finally trust the list.
Provenance isn’t overhead. It’s the debugger. Making every dashboard value trace back to a raw record felt like ceremony in week one. A few weeks later it caught a bug that would’ve stayed invisible forever. A customer showed as paying but with no subscription. The trace card said the subscription was never ingested. The billing API said it absolutely existed. Turned out the uniqueness key didn’t include record type, so any legacy account whose subscription ID happened to match its customer ID got silently dropped as a “duplicate.” A real chunk of the customer base came back with a one-line migration. I would not have found that without the trace.
Monitor your assumptions, not just your uptime. For weeks after go-live, every date in the system was frozen on the day the fixtures were generated. A flag meant for local dev had survived in the production env file, and it pinned the clock for the dashboard and the nightly compute both. Nothing crashed. Health checks were green. Anything that went quiet after that date looked fresh forever. The operator caught it by noticing an install that “last checked in weeks ago” had, in fact, checked in that morning. The self-monitor now compares the app’s clock to the system clock. That’s the kind of check you only think to write after it bites you.
Write the decisions down before the agent shows up. Most of this code was written with an AI coding agent. The single most useful file in the repo is a kickoff brief with a section titled “settled decisions, do not revisit.” Poll-only, no webhooks. One system is authoritative for license state, not its mirror. Secrets get stripped before the raw write. The failure mode with agents isn’t bad code. It’s the agent helpfully reversing a decision you made for a reason it can’t see. Versioned requirements, a memory log, and a checkpoint at every milestone turned a months-long project into something a fresh session can rejoin in five minutes.
The operator’s bug list is the roadmap. The best sessions all started the same way: “here’s a list of customers that look wrong.” Every single one traced to something real. A recycled ID. A reseller’s contact email gluing two companies together. An install that had genuinely been dark for months and needed an actual phone call. Building the tool was half the work. Sitting next to someone who knows the customers and letting them break it was the other half.
What’s next
The nightly cycle is live and the alerts queue is getting triaged. What’s left is a structural fix for the recycled IDs, a UI pass to match the company’s brand, and the ops debt every internal tool collects: alerting for the tool itself, and backups that actually run. None of it is glamorous. All of it is the difference between a demo and something people lean on.
If there’s one takeaway, it’s this: the hard part of an internal data tool isn’t the pipeline. It’s deciding what’s true when your sources disagree, and making sure you can always show your work.