<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nabeel Hassan</title>
    <description>The latest articles on DEV Community by Nabeel Hassan (@nabeelbaghoor).</description>
    <link>https://dev.to/nabeelbaghoor</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F900791%2Fc3f13182-5a2c-42a8-ba1c-645572cbf415.jpg</url>
      <title>DEV Community: Nabeel Hassan</title>
      <link>https://dev.to/nabeelbaghoor</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nabeelbaghoor"/>
    <language>en</language>
    <item>
      <title>Your Dashboard Is Just a Data Model Wearing a Chart</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Sat, 01 Aug 2026 21:48:43 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/your-dashboard-is-just-a-data-model-wearing-a-chart-3mco</link>
      <guid>https://dev.to/nabeelbaghoor/your-dashboard-is-just-a-data-model-wearing-a-chart-3mco</guid>
      <description>&lt;p&gt;Every dashboard project I have taken on was described to me as a front end problem. Charts, filters, a date range picker, maybe a CSV export. Then you open the codebase and find out the charts were never the problem.&lt;/p&gt;

&lt;p&gt;I work as a fractional CTO on a React, TypeScript and GraphQL app at Bettershop Consulting that puts an admin dashboard and analytics in front of Amazon sellers. On paper it is plumbing. In practice it is one of the least forgiving things I have built, because a seller reads a number on that screen and then reprices a product, kills an ad campaign, or spends real money on inventory.&lt;/p&gt;

&lt;p&gt;That changes the bar completely. A pretty chart that is subtly stale or quietly double counts a return is worse than no chart, because it launders a bad number into a confident decision. Nothing crashes. Nobody files a bug. The seller just loses money slowly and never connects it to your software.&lt;/p&gt;

&lt;p&gt;Here is what building analytics for people who act on it taught me, and why I now believe most dashboard work is backend work wearing a front end costume.&lt;/p&gt;

&lt;h2&gt;
  
  
  The product is an opinion, not a viewer
&lt;/h2&gt;

&lt;p&gt;Amazon hands sellers a firehose across a spread of surfaces: orders, returns, fees, advertising, inventory, settlements. The raw material is all there and almost none of it is decision ready.&lt;/p&gt;

&lt;p&gt;A seller does not want another table of rows. They want the two or three numbers that tell them what to do this week. Which products are actually profitable after every fee. Which ad spend is buying real orders. What is about to stock out.&lt;/p&gt;

&lt;p&gt;So the product is not a viewer, it is an opinion. Its job is to take a mess of source data and turn it into a small number of trustworthy answers. Once you accept that framing, the center of gravity moves out of the front end entirely and into the layer that ingests, reconciles and models the data. By the time a chart renders, all the hard thinking should already be finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  GraphQL earns its place here, but not for the reason people cite
&lt;/h2&gt;

&lt;p&gt;Reaching for GraphQL is often cargo cult. On this app it earns its keep for a concrete reason: what the client needs varies wildly per screen, and the underlying data is deeply relational.&lt;/p&gt;

&lt;p&gt;A profitability view needs products joined to orders joined to fees joined to returns. An advertising view needs campaigns joined to keywords joined to attributed sales. An inventory view needs stock joined to velocity joined to inbound shipments. With a pile of REST endpoints you either over fetch, dragging whole objects across the wire to use one field, or you sprout a bespoke endpoint per screen until the API is a graveyard of one off routes. GraphQL lets each screen ask for exactly the graph it needs in one round trip, and the type system doubles as a contract the TypeScript front end can trust.&lt;/p&gt;

&lt;p&gt;But the query language is not the real win. The real win is the discipline it forces on you. A schema makes you name your domain precisely. What is a product. What is an order line versus an order. What exactly is profit, and which fees live inside it. Writing that schema is writing down the business, and getting it right is most of the battle. A sloppy schema produces a sloppy dashboard no matter how good the React looks on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  One definition of profit, everywhere
&lt;/h2&gt;

&lt;p&gt;The single most important rule on this project: a number means the same thing on every screen.&lt;/p&gt;

&lt;p&gt;Profit on the overview card, profit in the product detail view and profit in an exported report have to be the identical calculation. Not three implementations that agree today and drift apart the first time someone adds a fee type. Computed once, surfaced everywhere.&lt;/p&gt;

&lt;p&gt;This is the same instinct I carry from leading a sports tech platform, where a leaderboard position is one authoritative object rendered on three different screens. Here it is one authoritative margin calculation surfaced in a dozen places. The failure mode is identical too: the moment two views disagree about a number, the user stops trusting all of them, and a dashboard nobody trusts is worse than no dashboard because you still pay to run it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard parts hide in the data, not the UI
&lt;/h2&gt;

&lt;p&gt;Charts are the easy half. The work that decides whether this product is any good lives underneath, in reconciliation nobody ever demos.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fees are a maze.&lt;/strong&gt; Amazon's fee structure is layered and it changes. A profit number that ignores a referral fee, an FBA fee, a storage fee or a return processing cost is not slightly off, it is telling a seller that a losing product is a winner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Returns arrive late.&lt;/strong&gt; A sale looks profitable until the return lands weeks later. If you book revenue and forget to claw back returns, the picture is systematically rosier than reality. A sale has to be modeled as provisional until the return window resolves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Currencies and marketplaces multiply.&lt;/strong&gt; A seller across several Amazon marketplaces has data in different currencies under different fee regimes. Rolling that into one comparable view without silently mixing units is fiddly, exact work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time zones move the numbers.&lt;/strong&gt; "Today's sales" depends on whose midnight you mean. Pick the wrong boundary and every daily figure is quietly shifted. That bug survives for months because nothing breaks, the numbers are just wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this shows up in a screenshot. All of it decides whether the dashboard can be trusted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making a data heavy front end feel instant
&lt;/h2&gt;

&lt;p&gt;Once the data is honest, the UI still has to be fast, because a seller checking numbers between other tasks will not sit through a spinner.&lt;/p&gt;

&lt;p&gt;The big trap is the classic N+1 hiding behind a clean looking GraphQL request. Ask for a hundred products and each product's fees, and with naive resolvers that becomes a hundred and one database trips. Batching at the data loader level and careful resolver design is what keeps a rich screen from being a slow one. This is the tax GraphQL charges for the flexibility it gives you, and it is worth paying, but you do have to pay it deliberately.&lt;/p&gt;

&lt;p&gt;The client side has its own version of the same discipline. Cache aggressively. Fetch the shape a screen needs rather than everything. Never let one expensive widget block the whole view from painting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never render a confident number you are not sure about
&lt;/h2&gt;

&lt;p&gt;The honesty rule carries into the UI. A dashboard should not show a final looking number while it is still loading or partially failed.&lt;/p&gt;

&lt;p&gt;If one data source is lagging, the correct move is to say so on that tile. Not to render a plausible zero. A blank that admits "still loading" is safer than a figure that looks settled and is not, because the user cannot tell the difference and will act on either. I hold AR overlays and voice agents to the same standard: signal uncertainty, never fake confidence. The systems that get people in trouble are not the ones that fail loudly, they are the ones that fail while looking fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a fractional CTO actually adds here
&lt;/h2&gt;

&lt;p&gt;Bettershop already had a capable team and a real product. Stepping in on something like that is not about writing the most code. It is about protecting the few decisions that are expensive to get wrong.&lt;/p&gt;

&lt;p&gt;On a seller analytics app those decisions are almost entirely about data integrity. The definition of profit. How returns and fees are handled. Where the single source of truth lives. What the app is allowed to display when it is unsure. My most valuable contribution most weeks is insisting those ledger level questions get answered precisely before anyone argues about chart colors.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell past me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The dashboard is the data model wearing a chart.&lt;/strong&gt; Get the schema and the reconciliation right and the UI is easy. Get them wrong and no amount of front end polish saves it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One number, one definition, computed once.&lt;/strong&gt; Otherwise trust erodes across the whole product, not just the screen that is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model the messy reality up front.&lt;/strong&gt; Late returns, layered fees, multiple currencies and time zone boundaries are the product. They are not edge cases to bolt on in v2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signal uncertainty.&lt;/strong&gt; A tile that admits it is degraded beats a confident number that is quietly wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Software people act on has to be honest before it is beautiful, and honest is much harder. That is the whole job on a seller analytics dashboard, and it is the standard I hold the AI systems I build now to as well.&lt;/p&gt;

&lt;p&gt;If you want more of how I think about this kind of work, I write about it at &lt;a href="https://nabeelbaghoor.com/blog" rel="noopener noreferrer"&gt;nabeelbaghoor.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>graphql</category>
      <category>typescript</category>
      <category>react</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Two Headsets, One Hologram: The Part Every Mixed Reality Demo Skips</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Sat, 01 Aug 2026 06:08:56 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/two-headsets-one-hologram-the-part-every-mixed-reality-demo-skips-273h</link>
      <guid>https://dev.to/nabeelbaghoor/two-headsets-one-hologram-the-part-every-mixed-reality-demo-skips-273h</guid>
      <description>&lt;p&gt;Every mixed reality demo you have seen on a conference stage is one person in a headset, moving a hologram around an empty room. It looks like the hard part is done. It is not. It is the easy version, and it quietly skips the only problem that matters once real users show up.&lt;/p&gt;

&lt;p&gt;Put a second person in that room, looking at the same object, and the engineering problem changes shape completely. Now two headsets have to agree, to the centimeter, on where that object lives and what it is doing, and they have to stay agreed while both people walk around it and grab it. That agreement is the whole discipline of collaborative MR. The 3D content is the part users see. The agreement is the part that decides whether the thing works.&lt;/p&gt;

&lt;p&gt;I have built shared mixed reality environments, and this is the mental model I wish someone had handed me before the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three problems wearing one trench coat
&lt;/h2&gt;

&lt;p&gt;A collaborative MR app is not one system. It is three, and each one can sink the experience on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared spatial anchoring.&lt;/strong&gt; Both headsets need a common coordinate system mapped onto the real room. That is the foundation. If headset A thinks the model sits on the table and headset B thinks it is fifty centimeters to the left, the shared illusion dies instantly, because each person is pointing at empty air as far as the other one is concerned. Establishing that shared frame and holding it as people move is the core problem the category exists to solve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;State synchronization.&lt;/strong&gt; Once the room is shared, the contents have to stay shared. Somebody rotates a model, moves a part, marks something up, and every other headset has to reflect that fast enough that it reads as one object rather than a laggy copy. This is exactly the problem that makes multiplayer games hard, and it carries the same tax: authority, conflict handling when two people grab the same thing, and graceful behavior when someone's network hiccups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Presence.&lt;/strong&gt; A shared room only feels shared if you can sense the other people in it. Where they are standing, what they are looking at, what they are pointing to. Representing position, gaze and gestures is its own layer of work, and it is usually what separates a session that feels collaborative from one that feels like ghosts editing a scene.&lt;/p&gt;

&lt;p&gt;If you scope only the first one, you get an object that everybody sees in the right place and nobody can meaningfully touch together. If you scope only the second, you get a beautifully synced object that lands in a different spot for each person. They are not optional relative to each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The version I shipped
&lt;/h2&gt;

&lt;p&gt;The clearest example from my own work is MR Camera, a mixed reality environment where several users place and interact with 3D models in the same shared space at the same time. Multiple people, each in their own headset, drop objects into one room and work with them together, seeing each other's changes live.&lt;/p&gt;

&lt;p&gt;That build only works because all three systems hold at once. The anchoring has to give every headset the same map of the room, so a model one person places lands in the same real spot for everyone else. The synchronization has to carry every placement, move and edit across all participants without drift. Presence has to make it obvious who is doing what, or people talk over each other and reach for the same object.&lt;/p&gt;

&lt;p&gt;The failure modes were never in the scene. They were in the seams. That matches what I found feeding live data into location based AR: the rendering is the well trodden part, and the integration underneath is where the real engineering time goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually moves the cost
&lt;/h2&gt;

&lt;p&gt;When someone describes a collaborative MR idea to me, I do not ask about the art. I ask five questions, because these are the multipliers that decide the build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many people share the space at once?&lt;/strong&gt; Two is a completely different system from twenty. Every extra participant adds synchronization load and new edge cases. Headcount is one of the biggest levers on the whole project, and people underestimate it constantly because in their head everyone is politely taking turns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Co-located, remote, or both?&lt;/strong&gt; People in the same physical room can share spatial anchors directly. Remote participants need a networked shared coordinate system and real infrastructure behind it. Supporting both is not one feature with a flag. It is two solutions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How precisely must objects align?&lt;/strong&gt; "Roughly on the table" and "this bolt lines up with that real hole" are different builds. Tight alignment held across multiple headsets is a guarantee you pay for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much can people edit together?&lt;/strong&gt; Viewing a shared model is light. Several people simultaneously moving, editing and building brings conflict handling and much heavier sync. This is the requirement that most often turns a two month project into a six month one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which headsets?&lt;/strong&gt; The target device sets your anchoring capabilities, your comfort ceiling and your cost per seat, and it ripples through every decision downstream. Decide it early or decide it twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I would sequence it
&lt;/h2&gt;

&lt;p&gt;The most expensive mistake in this space is commissioning a broad many user remote and local platform before proving that two headsets can agree on where one object is. I have watched that order of operations burn real budget.&lt;/p&gt;

&lt;p&gt;Prove two headset agreement first. Get two devices to hold one model in the same real spot, then stand in the room with a colleague and confirm it with your own eyes. Not in a screenshot, not in the editor. In the room. Everything downstream is comparatively predictable once shared anchoring feels solid.&lt;/p&gt;

&lt;p&gt;Start co-located before remote. Two people in one room is the simplest honest version of the problem. Nail that before you take on the networking and infrastructure that remote sessions demand.&lt;/p&gt;

&lt;p&gt;Treat synchronization as a first class workstream. If people edit together, the state sync, conflict handling and failure behavior deserve their own budget line, the same way they would in any multiplayer build. Sneaking it in as "we will just network the transforms" is how projects discover authority bugs three weeks before a demo.&lt;/p&gt;

&lt;p&gt;Add participants deliberately. Every extra person is a multiplier, not a free seat. Prove the interaction with a small group, then scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that saves you the most money
&lt;/h2&gt;

&lt;p&gt;Before any of this, one question decides whether you should be here at all: does a second person need to see and touch the same thing in the same place?&lt;/p&gt;

&lt;p&gt;If no, single user MR does the job for a fraction of the cost, and you should build that instead. I tell clients this regularly and it is not false modesty. Collaborative MR is a genuinely harder class of system, and paying for it when nobody needs it is the worst outcome in the category.&lt;/p&gt;

&lt;p&gt;If yes, then you are not building a 3D viewer. You are building a distributed system that happens to render in a room, and the thing to get right is the agreement, not the geometry.&lt;/p&gt;

&lt;p&gt;The 3D was never the hard part. Making a room full of headsets believe in the same object at the same time is.&lt;/p&gt;

&lt;p&gt;If you are scoping something like this and want the longer version, including the honest cost bands, I wrote it up at &lt;a href="https://nullstud.io/blog/mixed-reality-collaboration/" rel="noopener noreferrer"&gt;Null Studio&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ar</category>
      <category>unity3d</category>
      <category>gamedev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The Hardest Part of a White-Label SaaS Was One Login Form</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Thu, 30 Jul 2026 21:47:25 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/the-hardest-part-of-a-white-label-saas-was-one-login-form-455n</link>
      <guid>https://dev.to/nabeelbaghoor/the-hardest-part-of-a-white-label-saas-was-one-login-form-455n</guid>
      <description>&lt;p&gt;The hardest part of building a white-label SaaS was not the AI, the custom domains, or the billing. It was one login form.&lt;/p&gt;

&lt;p&gt;I build VoiceDash, a white-label platform where agencies resell AI voice agents to their own clients under their own brand. On paper it is a voice product. In practice, the thing that kept me up at night was authentication, because a white-label product has a structural problem most apps never face: one login form has to serve two completely different kinds of person, and neither one is ever allowed to become the other.&lt;/p&gt;

&lt;p&gt;This is the story of how I solved that with a single auth provider and one small discriminator field, and the edge-runtime gotcha that nearly broke the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two users who must never overlap
&lt;/h2&gt;

&lt;p&gt;The hierarchy looks like this. An agency signs up. That agency is a Workspace. Inside the workspace, the agency's own staff are WorkspaceMembers. The agency then creates Clients, and each Client has its own ClientMembers, the actual end users logging into a portal that is branded to look like the agency built it themselves.&lt;/p&gt;

&lt;p&gt;So there are two kinds of human hitting the login screen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Agency users. My paying customers. They log in with email and password, and they administer everything: clients, agents, billing, branding.&lt;/li&gt;
&lt;li&gt;Client members. My customers' customers. They log into a workspace-branded portal, they only ever see their own client's data, and they have no idea VoiceDash exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The invariant that matters more than anything else: a client member must never be able to reach an agency route, and must never see another client's data. If that breaks, one agency's customer can read another agency's customers. That is not a bug, that is the end of the business.&lt;/p&gt;

&lt;p&gt;The tempting move is to build two auth systems. Two providers, two session shapes, two sets of middleware, two of everything. I did not want to maintain two of everything. So I built one.&lt;/p&gt;

&lt;h2&gt;
  
  
  One provider, one discriminator
&lt;/h2&gt;

&lt;p&gt;VoiceDash runs on NextAuth with a single CredentialsProvider. The trick is a &lt;code&gt;type&lt;/code&gt; field on the credentials, either &lt;code&gt;"agency"&lt;/code&gt; or &lt;code&gt;"client"&lt;/code&gt;, and the &lt;code&gt;authorize&lt;/code&gt; function branches on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// client members log in by loginId OR email, password optional&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loginId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loginId&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;loginId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;clientMember&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientMember&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;loginId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="c1"&gt;// ...fall back to email lookup, verify password only if one is set...&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clientMember&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clientMember&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clientMember&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clientMember&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Member&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// agency users log in by email + password&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// ...bcrypt compare...&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;member&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;workspaceId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;member&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MEMBER&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two branches are genuinely different. Agency login is a plain email plus password. Client login accepts a &lt;code&gt;loginId&lt;/code&gt; or an email, and the password is optional, because some agencies onboard their clients with no password at all and let them in by login ID. Two flows, two lookups, two shapes of returned user. But one provider, and both return an object carrying &lt;code&gt;type&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;type&lt;/code&gt; is the entire security model in one word.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bake it into the token, read it everywhere
&lt;/h2&gt;

&lt;p&gt;A returned user object is not enough on its own. It has to survive into every future request. VoiceDash uses JWT sessions, so the discriminator gets written into the token once and read back on every request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// jwt callback: user -&amp;gt; token, on sign in&lt;/span&gt;
&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspaceId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// session callback: token -&amp;gt; session, on every request&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;workspaceId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every piece of the app can ask one question, &lt;code&gt;session.type&lt;/code&gt;, and know exactly who it is talking to. Data queries scope by &lt;code&gt;workspaceId&lt;/code&gt; or &lt;code&gt;clientId&lt;/code&gt; from the token, never from anything the client sends in the request. That last part is the whole game: the tenant boundary comes from the signed token, not from a URL parameter or a request body a client could tamper with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate that enforces it
&lt;/h2&gt;

&lt;p&gt;All of that would be theory without one place that actually turns people away. In this version of Next.js the middleware file is &lt;code&gt;proxy.ts&lt;/code&gt;, and it is the single gate every request passes through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// agency routes require an agency session&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/agency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/client/agents&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;type !== "agency"&lt;/code&gt; check is load bearing. A logged-in client member who types &lt;code&gt;/agency/clients&lt;/code&gt; into the address bar does not get a 500 or a blank page, they get bounced back to their own portal. The same file also splits the root path: on the app subdomain the root sends you to your dashboard or the login page, while on the marketing domain the root renders the public landing page. One file, four decisions, all driven off the same token.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha that cost me an afternoon: edge cannot see your database
&lt;/h2&gt;

&lt;p&gt;Here is the part I wish someone had told me. Middleware runs on the edge runtime. The edge runtime cannot import bcrypt, and it cannot import Prisma. If you try, your build does not fail politely, it fails at the exact moment the middleware tries to load, which feels like the auth itself is broken.&lt;/p&gt;

&lt;p&gt;The fix is to split the config in two. There is an edge-safe &lt;code&gt;auth.config.ts&lt;/code&gt; that holds only the callbacks, the pages, the session strategy, and an empty &lt;code&gt;providers: []&lt;/code&gt; array. It imports nothing from Node. Then a separate &lt;code&gt;auth.ts&lt;/code&gt; spreads that config and adds the real CredentialsProvider with bcrypt and Prisma, and that file only ever runs in the Node runtime where those imports are legal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// auth.config.ts, edge safe, imported by proxy.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextAuthConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jwt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="c1"&gt;// real providers added in auth.ts&lt;/span&gt;
  &lt;span class="na"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* jwt + session, no Node imports */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middleware imports the edge-safe config so it can read and validate the token without ever touching bcrypt or the database. The API routes import the full &lt;code&gt;auth.ts&lt;/code&gt;. It feels like duplication the first time you see it. It is not. It is the boundary between "code that can run at the edge" and "code that needs a database," drawn on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself before starting
&lt;/h2&gt;

&lt;p&gt;The discriminator field is the cheapest possible way to serve two audiences from one auth system, and it is also the single most dangerous line in the codebase. One missing &lt;code&gt;type&lt;/code&gt; check is a privilege escalation, not a cosmetic bug. So I treat it the way I treat any invariant: I assume it will be forgotten somewhere, and the gate in &lt;code&gt;proxy.ts&lt;/code&gt; exists precisely so that forgetting it in a page component does not become a breach.&lt;/p&gt;

&lt;p&gt;If you are building anything where your customer has customers, this is the shape to reach for. One provider, one discriminator baked into the token, one gate that enforces it, and a clean edge and Node split so your middleware can validate identity without dragging your database to the edge.&lt;/p&gt;

&lt;p&gt;If you want to see the finished product it powers, it is at &lt;a href="https://voice-dash.com" rel="noopener noreferrer"&gt;voice-dash.com&lt;/a&gt;. But the auth model above is the part I am actually proud of, and it is the part that took the longest to get right.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>authentication</category>
      <category>saas</category>
    </item>
    <item>
      <title>Nobody Hands You Day One: Stepping In as CTO on a Live Product Across Three Runtimes</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Tue, 28 Jul 2026 21:45:33 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/nobody-hands-you-day-one-stepping-in-as-cto-on-a-live-product-across-three-runtimes-1cdp</link>
      <guid>https://dev.to/nabeelbaghoor/nobody-hands-you-day-one-stepping-in-as-cto-on-a-live-product-across-three-runtimes-1cdp</guid>
      <description>&lt;p&gt;Most engineering stories start at day one with a clean repo. This one did not, and I think the messy version is the more useful one to read.&lt;/p&gt;

&lt;p&gt;I stepped in as CTO at RAQTS, a sports-tech platform that connects players and facilities, during a phase where the stakes were high and there was no room to stop and rebuild. The product already existed. It already had real players and real facilities depending on it. My job was not to design something from scratch. It was to steady something in motion and keep shipping while it ran.&lt;/p&gt;

&lt;p&gt;If you have only ever started projects, that distinction sounds academic. It is not. It changes the first thing you do, the first thing you touch, and the order you are allowed to touch things in. Here is what the job actually taught me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first task is reading, not writing
&lt;/h2&gt;

&lt;p&gt;When you join a live system, the cost of a careless change is not a failed test. It is a facility that cannot run its session tonight because you shipped a change to a subsystem you did not fully understand yet.&lt;/p&gt;

&lt;p&gt;So the first week was reading, not writing. How did the Unity layer talk to the backend? Where did the mobile apps get their data? What was the web portal actually responsible for, and where were the seams between all three fragile? I could not lead confidently until that map was in my head, because leadership on a live product is mostly about knowing what you are not allowed to break.&lt;/p&gt;

&lt;p&gt;The instinct is the same one every senior engineer eventually learns the hard way: move fast, but earn the right to move fast by understanding the ground first. On a greenfield project you earn that right by building. On a live one you earn it by reading.&lt;/p&gt;

&lt;p&gt;A critical phase also forces triage. Not everything can be fixed at once, and pretending otherwise is how you stall out completely. The question I kept asking was narrow: which few things most decide whether this platform stays trustworthy? The answer was the release pipeline and the live data. So that is where I put my weight first, and I let a lot of other imperfect things stay imperfect on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  One product, three runtimes
&lt;/h2&gt;

&lt;p&gt;The hardest thing about RAQTS technically was that it was one product living in three very different worlds at once.&lt;/p&gt;

&lt;p&gt;A player might interact with an interactive Unity experience called Lumo, check their standing on a React Native mobile app, and a facility might manage everything from a Next.js web portal. To the user, that is one system. Underneath, it is three stacks with three release cadences and three sets of assumptions.&lt;/p&gt;

&lt;p&gt;The discipline that keeps a split like this from fracturing is a rule I have carried across every cross-platform product I have shipped: share the truth, never the platform. There has to be one authoritative source of what a score is, what a session is, what a leaderboard says. Each runtime is just a different window onto that same truth.&lt;/p&gt;

&lt;p&gt;The moment two clients start disagreeing about the state of the world, the product feels broken even if every individual app is technically working. That is the failure mode that matters, and no amount of per-app polish saves you from it.&lt;/p&gt;

&lt;p&gt;The Unity layer is a good example of the trap. Lumo is rich and interactive, and a rich client is exactly the kind of thing that wants to own its own state because that feels faster and smoother. But an interactive layer inside a larger product cannot be the place where a score is invented. It has to render the experience beautifully while treating the backend as the authority for anything that will show up somewhere else a second later. A compelling visual layer that quietly lies about the underlying data is worse than a plain one that is always honest.&lt;/p&gt;

&lt;p&gt;Same logic for mobile versus web. The React Native apps and the Next.js portal serve different people. Players live on mobile for quick checks and their own standing. Facilities live on the portal for management and the wider view. The temptation is to make one a shrunken copy of the other, but a facility operator's needs are not a player's needs scaled down. They are a different job. What they share is not the UI. It is the data layer and the outcome model. A leaderboard position is the same object whether a player sees it on their phone or a facility sees it on a dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  A live leaderboard is a distributed systems problem
&lt;/h2&gt;

&lt;p&gt;The feature that sounds trivial and is not is the live leaderboard.&lt;/p&gt;

&lt;p&gt;A leaderboard is easy if it is allowed to be a few minutes stale. It becomes a real engineering problem the moment it has to be live, because now every client has to converge on the same ordering, fast, while scores are still changing underneath them. The hard parts are the ones that never show up in a demo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Everyone has to agree.&lt;/strong&gt; A player on their phone and a facility on the portal must see the same standings within moments of each other. A leaderboard that shows two different orders on two screens destroys trust instantly, because the entire point of a leaderboard is that it is the shared truth. This is a convergence problem, not a rendering one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It has to survive a bad connection.&lt;/strong&gt; Mobile networks drop. A client that misses an update cannot be left showing a frozen, wrong board forever. It has to reconcile when it reconnects and catch up cleanly. Design for being interrupted, the same way you would design any client that cannot assume a stable link.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The write path is the truth, the read path is fast.&lt;/strong&gt; Scores get written in one place with authority and read in many places quickly. Keeping that separation clean is what lets the board be both correct and responsive instead of trading one for the other. If you have reached for a CQRS-shaped split before, this is the same instinct wearing a jersey.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule I held was simple and non-negotiable: the backend owns the ordering, always. No client ever computes the leaderboard from its own partial view and shows that as fact. Clients render what the authority tells them and reconcile toward it. That is slower to build than letting each app do its own math, but it is the only way three runtimes ever agree on who is winning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The store cadence is not yours
&lt;/h2&gt;

&lt;p&gt;The unglamorous half of a mobile product is release management, and on a live platform it is where a surprising amount of the real risk sits.&lt;/p&gt;

&lt;p&gt;I managed App Store and Play Console releases for RAQTS, and anyone who has done it knows the calendar is not yours. Review times, signing, store policy, staged rollouts, and one fact that shapes everything: a shipped mobile build cannot be hot-fixed the way a web deploy can.&lt;/p&gt;

&lt;p&gt;A Next.js portal can be fixed and redeployed in minutes. A React Native app in a bad state is on users' phones until the next review clears. So the mobile bar has to be higher, the testing more paranoid, and the backend has to stay forgiving of older app versions still in the wild, because not everyone updates the day you ship. Designing the server to stay compatible with clients you no longer control is a permanent tax of shipping to app stores. Forgetting it is how a clean release quietly becomes an outage for the slow updaters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually taught me
&lt;/h2&gt;

&lt;p&gt;Four things generalize well beyond sports tech:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Steady before you change.&lt;/strong&gt; Joining mid-flight means understanding the moving system before you touch it. Triage the few things that decide trust, and start there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One truth, many windows.&lt;/strong&gt; Unity, mobile, and web are just views. The score, the session, the leaderboard live in one authoritative place, and every client defers to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live is a systems problem, not a UI one.&lt;/strong&gt; A real-time leaderboard is about convergence, reconnection, and a clean write-versus-read split, not about the pixels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect the store cadence.&lt;/strong&gt; Mobile builds you cannot hot-fix demand a higher bar and a backend that stays kind to old versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The through line from Unity games to AR platforms to AI agents, an arc I have been on for years, is that the tools change and the judgement does not. Knowing what a product cannot afford to get wrong, and building the boundary around that first, is the same skill whether it is a firefighter's AR overlay, a background SDK, a voice agent, or a live leaderboard that a room full of players is watching in real time.&lt;/p&gt;

&lt;p&gt;If you want the longer version of my background, it is &lt;a href="https://nabeelbaghoor.com/#about" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>career</category>
      <category>mobile</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>The Model Is the Easy Part: What a Real-Time Computer Vision Product Actually Takes</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Mon, 27 Jul 2026 08:50:40 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/the-model-is-the-easy-part-what-a-real-time-computer-vision-product-actually-takes-4gk7</link>
      <guid>https://dev.to/nabeelbaghoor/the-model-is-the-easy-part-what-a-real-time-computer-vision-product-actually-takes-4gk7</guid>
      <description>&lt;p&gt;I used to think computer vision was a model problem. It is a pipeline problem, and the model is the easy part.&lt;/p&gt;

&lt;p&gt;Here is the pattern I keep seeing. A team trains or fine-tunes a detector, it hits some impressive number on a curated clip, everyone claps, and then it goes into the real world and falls over. The lighting is wrong. The camera moved. Someone wore a color the training set never saw. The response arrives half a second late and the whole thing feels broken. The demo worked. The product did not.&lt;/p&gt;

&lt;p&gt;I have shipped enough camera-driven products now to believe the mental model most people start with is wrong. Computer vision is not "a model that recognizes things." It is a real-time loop: sense, decide, act, fast and reliable, in a world you do not control. The model is one node in that loop. Getting the model to work is table stakes. Getting the loop to hold up is the actual engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model is a node, not the product
&lt;/h2&gt;

&lt;p&gt;If you have built any real-time system, this framing will feel familiar. Think of the vision model the way you think of a single service in a request path. It takes an input, produces an output, and has a latency cost. It is not the system. The system is everything around it: where the frames come from, what happens to the output, how fast the whole chain runs, and what the product does when the model is wrong (because it will be wrong).&lt;/p&gt;

&lt;p&gt;Detection is only half a product. A useful vision system closes a loop. It senses something, decides what that means, and then does something visible in the real world, fast enough that the response feels connected to the event. Sensing without a fast, dependable response is a science project, not a product.&lt;/p&gt;

&lt;p&gt;The clearest example I can point to is Raqts, a product I handle all the software development for. It is a responsive racquet-sport wall: you hit the wall, and it reacts to how and where you hit it, in real time. The camera and vision pipeline read the play, and the system responds. That only works if every stage of the loop is quick and reliable. A late read does not make the wall "slightly less accurate." It makes the wall feel broken. Real-time is a binary experience, not a gradient.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things that decide whether it works
&lt;/h2&gt;

&lt;p&gt;A vision demo is easy. A vision product is not. Four drivers decide which one you end up with, and they are where the real budget and risk live. None of them are "pick a better model."&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Real-world accuracy, which is a different number
&lt;/h3&gt;

&lt;p&gt;The accuracy you get on a curated clip and the accuracy you get in production are two different numbers, and only one of them matters. The physical world is adversarial in the same way untrusted user input is adversarial. It will hand you the lighting you did not test, the angle you did not mount for, the background clutter your clean data never had.&lt;/p&gt;

&lt;p&gt;Treat it like input validation. You do not trust a number from a controlled recording any more than you trust a request body because it parsed once. Real accuracy is bought with representative data, hard-case testing, and honest measurement of where the model fails. The first question to ask is never "how accurate is it" but "accurate under which real conditions, and how do you know."&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The latency budget is a hard constraint
&lt;/h3&gt;

&lt;p&gt;For anything interactive, the response has to feel tied to the event. That gives you an end-to-end budget, camera to inference to action, and it is a hard engineering constraint the same way a frame budget is in a game loop or a p99 target is in a service. It shapes every other choice: which model, running where, on what hardware.&lt;/p&gt;

&lt;p&gt;A system that is accurate but slow fails a real-time product just as surely as one that is fast but wrong. If Raqts read a hit a half-second late, no amount of accuracy would save the feel of it. You are engineering for both at once, and the budget is fixed before you pick the model, not discovered after.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Where the model runs is an architecture decision
&lt;/h3&gt;

&lt;p&gt;A vision model can run in the cloud, where compute is cheap and flexible, or on the edge, on a device next to the camera. This is the same cloud-versus-local trade you already know from anywhere else, with the same shape.&lt;/p&gt;

&lt;p&gt;Cloud is simpler to build and update but adds network round-trips and a hard dependency on connectivity. Edge keeps latency low and works offline but constrains you to what the on-site hardware can handle and makes updates harder. Real-time and privacy-sensitive products lean edge. Batch or tolerant ones lean cloud. This single decision ripples through cost, speed, and reliability, so make it deliberately and early, against your actual latency and connectivity numbers, not out of habit.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The hardware seams are each a small project
&lt;/h3&gt;

&lt;p&gt;Vision products rarely live in software alone. There are cameras to choose and place, mounts and lighting to control, and often actuators, sensors, or displays the software has to drive in sync. This is the IoT half of the build, and it carries the weight of any hardware seam: timing, failure modes, and physical constraints a pure web app never faces.&lt;/p&gt;

&lt;p&gt;Raqts is a wall, cameras, and responsive feedback working as one system. That is a genuinely different build from a screen-only app. The lesson I keep relearning: count the hardware seams honestly, because each one is a small project inside the project, the same way every third-party integration is a small project inside a normal build. The failure modes live in the seams, not in the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I scope one now
&lt;/h2&gt;

&lt;p&gt;The discipline is the same one I use for any zero-to-one build: name the risky parts before anyone quotes a number. For computer vision that comes down to five questions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Define the one decision the model must make.&lt;/strong&gt; Detect, classify, count, or track. One clear vision task, done reliably, beats five fuzzy ones. Everything else is downstream software.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the real-world conditions.&lt;/strong&gt; Lighting, angles, distances, speeds, and the edge cases that actually occur. This list is the difference between a demo and a product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the latency budget.&lt;/strong&gt; Decide how fast the response must feel, because that constraint chooses your model and where it runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide edge versus cloud on purpose.&lt;/strong&gt; Against your latency, connectivity, and privacy needs, not out of habit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count the hardware seams.&lt;/strong&gt; Cameras, mounts, lighting, and anything the system has to physically drive in response.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pin those five down and the estimate stops being a leap of faith and becomes a conversation about a defined thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI actually changed here
&lt;/h2&gt;

&lt;p&gt;This is the part that genuinely shifted, and it is not the part people expect. A few years ago a custom vision capability meant collecting a large dataset and training a model from scratch, slow and expensive enough that only big budgets attempted it. Modern foundation models and mature vision tooling now get a strong first version working far faster.&lt;/p&gt;

&lt;p&gt;But notice what that does. It does not remove the hard work. It moves it. When the model is cheap to stand up, more of your effort lands on the parts that were always the real work: the real-world accuracy, the latency loop, and the hardware integration. The scarce skill stopped being "can you train a model" and became "do you know which vision task actually solves the problem, and where the real-world failure modes hide."&lt;/p&gt;

&lt;p&gt;That is the honest state of computer vision right now. The barrier to a working prototype dropped through the floor. The barrier to a working product did not move at all, because it was never the model. It was always the loop.&lt;/p&gt;

&lt;p&gt;A computer vision product is not a model. It is a loop: see, decide, act, fast and reliable, in a world you do not control. Get the four drivers right and you have software that senses the physical world and responds to it. Skip them and you have a demo that will never survive contact with a real room.&lt;/p&gt;

&lt;p&gt;If you want the longer buyer-facing version of this, with the checklist I hand clients, I wrote it up on the Null Studio blog at &lt;a href="https://nullstud.io/blog/computer-vision-product-development" rel="noopener noreferrer"&gt;nullstud.io&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>computervision</category>
      <category>architecture</category>
    </item>
    <item>
      <title>An AI Voice Agent Is Just Two Webhooks: Wiring Retell to n8n in Production</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Fri, 24 Jul 2026 21:45:22 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/an-ai-voice-agent-is-just-two-webhooks-wiring-retell-to-n8n-in-production-2afj</link>
      <guid>https://dev.to/nabeelbaghoor/an-ai-voice-agent-is-just-two-webhooks-wiring-retell-to-n8n-in-production-2afj</guid>
      <description>&lt;p&gt;A voice agent that answers the phone is the easy 20 percent. The interesting 80 percent is everything the call is supposed to trigger: check the calendar, write the CRM contact, send the confirmation text, tag the hot lead. New developers building their first voice agent almost always try to cram that logic into the voice prompt, and it works in the demo and falls apart in production.&lt;/p&gt;

&lt;p&gt;I build AI voice agents for US clients, and the question I get most from other devs is some version of "okay, the agent talks, but how does it actually &lt;em&gt;do&lt;/em&gt; anything?" In my stack the answer is always the same: Retell handles the conversation, n8n handles everything the conversation triggers. This is the exact integration I ship, including the parts that break once real calls start hitting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: two webhooks pointing in opposite directions
&lt;/h2&gt;

&lt;p&gt;Almost everyone overcomplicates this. The entire integration is two arrows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retell to n8n:&lt;/strong&gt; the agent tells n8n what happened (call lifecycle events) and asks n8n questions mid-call (custom functions).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;n8n to Retell:&lt;/strong&gt; n8n tells Retell to do something (place an outbound call, update an agent, fetch a transcript).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get those two directions right and everything else is composition. The reason I keep the logic in n8n and not in the prompt is the same reason you do not hardcode business rules into your view layer: if you bake booking logic into the voice prompt, you have married the platform. Keep it in n8n and the voice layer becomes swappable. Switching or A/B testing platforms later is a change to one webhook, not a rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Direction 1: Retell to n8n (the inbound webhook)
&lt;/h2&gt;

&lt;p&gt;This is where most of the work lives. Retell can call your webhook on lifecycle events and on custom functions you define inside the agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set up the n8n side first.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a &lt;strong&gt;Webhook&lt;/strong&gt; node. Method &lt;strong&gt;POST&lt;/strong&gt;, and copy the production URL it generates. If you self-host, the URL has to be publicly reachable, because Retell's servers hit it directly, so &lt;code&gt;localhost&lt;/code&gt; will not work. For local dev I tunnel with ngrok and swap the URL for the real one before going live.&lt;/li&gt;
&lt;li&gt;Add a &lt;strong&gt;Respond to Webhook&lt;/strong&gt; node so Retell gets a clean &lt;code&gt;200&lt;/code&gt;. For mid-call functions this response &lt;em&gt;is&lt;/em&gt; the answer the agent speaks back, so it matters more than it looks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Point Retell at it.&lt;/strong&gt; In the agent settings, set the webhook URL to the n8n production URL. Retell now POSTs a JSON body on each event. The payload carries an &lt;code&gt;event&lt;/code&gt; field (&lt;code&gt;call_started&lt;/code&gt;, &lt;code&gt;call_ended&lt;/code&gt;, &lt;code&gt;call_analyzed&lt;/code&gt;) and the full &lt;code&gt;call&lt;/code&gt; object with the transcript, metadata, and any dynamic variables you passed in.&lt;/p&gt;

&lt;p&gt;A habit that pays off: put a &lt;strong&gt;Switch&lt;/strong&gt; node right after the webhook that branches on &lt;code&gt;{{ $json.event }}&lt;/code&gt;. Each event wants different handling. &lt;code&gt;call_ended&lt;/code&gt; is usually where I write the contact and transcript to the CRM. &lt;code&gt;call_analyzed&lt;/code&gt; is where I read Retell's post-call analysis (sentiment, whether a booking happened, custom extraction fields) and route on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mid-call custom function.&lt;/strong&gt; This is the powerful part. Inside the agent you define a &lt;strong&gt;custom function&lt;/strong&gt;, say &lt;code&gt;check_availability&lt;/code&gt; or &lt;code&gt;book_appointment&lt;/code&gt;. When the caller asks about a Tuesday slot, the agent calls that function, which hits your n8n webhook mid-conversation, waits, and speaks the result back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Caller: "Do you have anything Tuesday afternoon?"
  -&amp;gt; Retell fires custom function check_availability -&amp;gt; n8n webhook
  -&amp;gt; n8n queries Google Calendar / GHL calendar
  -&amp;gt; n8n responds { "slots": ["2pm", "3:30pm"] }
  -&amp;gt; Agent: "I have 2pm or 3:30 on Tuesday, which works?"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constraint that will bite you here is &lt;strong&gt;latency&lt;/strong&gt;. The caller is sitting in silence while n8n runs. Keep mid-call workflows lean: one lookup, a fast response, no chained API calls that eat four seconds. If a step is genuinely slow, have the agent say "give me one moment" and design around it. Anything non-blocking, like sending the confirmation SMS or writing the CRM note, does not belong in the mid-call path. Move it to the &lt;code&gt;call_ended&lt;/code&gt; branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Direction 2: n8n to Retell (the outbound call)
&lt;/h2&gt;

&lt;p&gt;The reverse direction uses Retell's REST API. The most common use is placing an outbound call, which is the backbone of any missed-call callback system.&lt;/p&gt;

&lt;p&gt;In n8n, an &lt;strong&gt;HTTP Request&lt;/strong&gt; node:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;POST&lt;/strong&gt; to Retell's create-phone-call endpoint.&lt;/li&gt;
&lt;li&gt;Authorization header with your Retell API key. Store it in n8n &lt;strong&gt;credentials&lt;/strong&gt;, never inline in the node. This is the single most common mistake I see in shared workflows, an API key sitting in plaintext in an exported JSON.&lt;/li&gt;
&lt;li&gt;Body: the &lt;code&gt;from_number&lt;/code&gt; (your registered Twilio number), the &lt;code&gt;to_number&lt;/code&gt; (the lead), the &lt;code&gt;agent_id&lt;/code&gt;, and a &lt;code&gt;retell_llm_dynamic_variables&lt;/code&gt; object carrying anything the agent should know before it dials: the caller's name, what they enquired about, the business name.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those dynamic variables are how you personalize a call without editing the agent. The prompt references &lt;code&gt;{{customer_name}}&lt;/code&gt;, n8n fills it at call time. One agent, infinitely reusable across contacts. If you have ever templated an email in code, it is the same idea pointed at a phone call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full loop, end to end
&lt;/h2&gt;

&lt;p&gt;Here is how the two directions combine in a real missed-call callback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Missed call in GHL -&amp;gt; GHL webhook -&amp;gt; n8n
  -&amp;gt; n8n HTTP Request -&amp;gt; Retell create-phone-call (lead name + context)
  -&amp;gt; Retell dials the lead, agent talks
  -&amp;gt; mid-call: agent calls book_appointment -&amp;gt; n8n webhook -&amp;gt; GHL calendar -&amp;gt; responds
  -&amp;gt; call_ended -&amp;gt; Retell webhook -&amp;gt; n8n
  -&amp;gt; n8n writes transcript + outcome to the GHL contact, sends SMS confirmation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every arrow in that diagram is one of the two webhook directions above. Nothing exotic. Just discipline about where each piece of logic lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production gotchas I have actually hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A2P 10DLC first.&lt;/strong&gt; If your flow sends SMS through Twilio, unregistered traffic gets silently filtered. Register the brand and campaign before you test, or you will lose a day debugging texts that never arrive and were never going to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency on &lt;code&gt;call_ended&lt;/code&gt;.&lt;/strong&gt; Retell can, in rare cases, deliver an event more than once. Guard CRM writes with the &lt;code&gt;call_id&lt;/code&gt; so a duplicate event does not create a duplicate contact. Treat webhooks as at-least-once, like you would any queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secure the webhook.&lt;/strong&gt; Verify Retell's signature, or at minimum put a shared secret in a header, so random POSTs cannot trigger your workflow. An open n8n webhook that places outbound calls is a genuine liability, not a hypothetical one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate test and production webhooks.&lt;/strong&gt; n8n gives you a test URL and a production URL and they are not the same endpoint. I have watched people wire the test URL into a live agent and wonder why nothing fires after they close the editor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the raw payload.&lt;/strong&gt; Early in a build I write every incoming Retell payload to a Sheet or a database node. When something misbehaves in week two, the raw event history is the fastest way to see what the agent actually sent versus what I assumed it sent.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this goes from here
&lt;/h2&gt;

&lt;p&gt;Once the two directions are solid, everything else is just adding nodes: swap the CRM, fire a Slack alert when a caller sounds angry, branch on the post-call analysis to tag hot leads. The voice agent stays dumb and simple, and the intelligence lives in n8n where you can see it, test it, and change it without ever touching the prompt. That separation is the whole trick, and it is why I have never had to rebuild a client's system just because they wanted to try a different voice platform.&lt;/p&gt;

&lt;p&gt;I write up the rest of this stack, platform choice, the testing pipeline, and what these agents cost to run, over on &lt;a href="https://nabeelbaghoor.com/blog" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;. If you are wiring your first one together, start with the two arrows and resist the urge to be clever inside the prompt.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>tutorial</category>
      <category>n8n</category>
    </item>
    <item>
      <title>I Put Real Planes in the Sky With AR. The 3D Was the Easy Part.</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Thu, 23 Jul 2026 21:41:25 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/i-put-real-planes-in-the-sky-with-ar-the-3d-was-the-easy-part-5576</link>
      <guid>https://dev.to/nabeelbaghoor/i-put-real-planes-in-the-sky-with-ar-the-3d-was-the-easy-part-5576</guid>
      <description>&lt;p&gt;A few years ago I would have told you AR was a graphics problem. You get a 3D model, you get a camera feed, you draw one on top of the other. Ship it.&lt;/p&gt;

&lt;p&gt;Then I built location-based AR for real, and the graphics turned out to be the least interesting part of the whole thing.&lt;/p&gt;

&lt;p&gt;The build that taught me this was Planes XR, one of the location-based AR experiences I worked on for ARCortex. The pitch is simple to say and brutal to deliver: point your phone at the sky, and the actual aircraft flying overhead show up, positioned where they really are. Not a canned demo of a plane. The plane that is genuinely up there right now, pulled from live flight data, rendered in the correct patch of sky.&lt;/p&gt;

&lt;p&gt;If you have only ever done marker-based or surface AR, that one requirement changes almost every engineering decision you make. So I want to walk through why, because the lesson generalizes well past AR.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three flavors of AR are not the same job
&lt;/h2&gt;

&lt;p&gt;It is worth being precise, because "AR" gets used for three genuinely different builds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Marker-based AR&lt;/strong&gt; shows content when the camera recognizes a specific image or QR code. Reliable, but something physical has to be in frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Surface AR&lt;/strong&gt; drops content onto a detected flat surface near you, a floor or a table. This is your furniture-in-your-living-room app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Location-based AR&lt;/strong&gt; anchors content to real-world coordinates, so it works across a whole site, a city, or the open sky, with no marker at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Marker and surface AR are, engineering-wise, mostly solved problems with good SDK support. The moment your experience has to span something bigger than a tabletop, you cross into location-based territory, and the difficulty steps up hard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Location-based AR is really three systems pretending to be one
&lt;/h2&gt;

&lt;p&gt;A location-based AR app has to know three things at the same instant: where the user is, which way they are facing, and where the content is supposed to live in the world. Get all three aligned and a digital object sits convincingly in a real street or a real sky. Get any one wrong and the illusion collapses instantly, because humans are shockingly good at noticing when something floats in the wrong spot.&lt;/p&gt;

&lt;p&gt;That splits into three subsystems, and each one is a place the whole thing can quietly fall apart.&lt;/p&gt;

&lt;h3&gt;
  
  
  Positioning
&lt;/h3&gt;

&lt;p&gt;The app fuses several signals to place you in the world: GPS for a rough fix, the compass and motion sensors for heading, and the camera itself for fine correction. The thing nobody tells you up front is that raw GPS is only accurate to several meters. That is completely fine for "you are near this landmark" and completely useless for "this arrow points at that specific doorway." Closing that gap, from good-enough-for-a-map to good-enough-to-overlay, is one of the core engineering problems in the entire category. You do not solve it with a better model. You solve it with sensor fusion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Anchoring
&lt;/h3&gt;

&lt;p&gt;Once you know where the user is, you have to lock the content to the world and hold it there as they walk, turn, and pan around. If the anchor drifts even slightly, the object slides off its spot and the experience reads as broken. Stable anchoring is the whole difference between an overlay that feels physically present and one that visibly wobbles, and it gets harder outdoors, at distance, and across large areas, which is to say exactly the conditions location-based AR lives in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data
&lt;/h3&gt;

&lt;p&gt;Here is the part that ate the most of my time. The interesting location-based experiences are almost never showing a static model. They are showing something tied to what is happening right now at that place, which means a live-data pipeline feeding the immersive layer. For Planes XR that meant pulling live aircraft positions from OpenSky, keeping them current, and rendering each plane where it actually is in the sky relative to where the user is standing and pointing.&lt;/p&gt;

&lt;p&gt;That pipeline, fetching the data, cleaning it, keeping it fresh, and handling the moments it is slow or simply unavailable, was as much work as the AR rendering itself. The overlay is the part users see. The data plumbing is the part that decides whether they trust it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure modes live in the seams, not the scene
&lt;/h2&gt;

&lt;p&gt;That sentence is the thing I actually took away from the build, and it is why I keep bringing it up in other work.&lt;/p&gt;

&lt;p&gt;Every part of Planes XR that was hard to get right lived in a seam between two systems, not inside any one of them. The 3D plane model rendered fine on day one. What broke was the plane rendering in the correct spot only when positioning, heading, and the data feed all agreed at the same moment. When a GPS reading was stale, or the compass drifted a few degrees, or the OpenSky feed lagged, no single component was "wrong," but the plane ended up in the wrong piece of sky and the whole thing felt fake.&lt;/p&gt;

&lt;p&gt;You debug those problems by staring at the boundaries between subsystems, not by staring at any subsystem alone. That is a very different debugging mindset from a normal app, where a bug usually lives in one function you can put a breakpoint in.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I scope one now, so it does not become a science project
&lt;/h2&gt;

&lt;p&gt;The most expensive mistake in this category is commissioning a broad, city-scale, multi-feature experience before proving the core alignment even feels right. So the way I approach it now is deliberately narrow at the start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prototype the hardest alignment in week one.&lt;/strong&gt; If the whole thing hinges on content landing accurately at a real place, build exactly that first, at one single location, and then go physically stand in the real spot with a real device. Everything else is comparatively predictable once positioning and anchoring feel right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prove one location before you build a hundred.&lt;/strong&gt; Nail a single site, or a single sky, measure whether the overlay holds and the data stays trustworthy, then expand. Coverage is a multiplier, not a starting point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the data pipeline as its own project.&lt;/strong&gt; If live data is part of the value, budget for it as a first-class workstream, including the unglamorous parts: freshness, error handling, and graceful failure when a feed drops. It will be bigger than you think.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start on phone AR unless a headset is truly essential.&lt;/strong&gt; Phones reach the widest audience with no hardware to buy, which makes them the right place to validate the concept before you commit to a headset's economics.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test for whether you even need location-based AR is one honest question: does being at the real place matter? If yes, this is the tool, and it is worth the extra difficulty. If the content would work just as well on your coffee table or from scanning a printed marker, use a cheaper AR mode and do not pay for this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that transfers
&lt;/h2&gt;

&lt;p&gt;I do not build AR every week. But "the hard, valuable engineering is in the alignment and the data, not the thing you can see" turned out to be one of the more portable lessons I have picked up. It is true of AR. It is true of most systems that touch the physical world, and it is true of a surprising number that do not. The demo shows you the scene. The product lives or dies in the seams.&lt;/p&gt;

&lt;p&gt;Planes XR stopped being a novelty the moment those seams held. That is the whole game.&lt;/p&gt;

&lt;p&gt;If you want the longer, scoping-oriented version of this with the full cost breakdown, I wrote it up on the studio blog at &lt;a href="https://nullstud.io/blog/location-based-ar" rel="noopener noreferrer"&gt;nullstud.io&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ar</category>
      <category>architecture</category>
      <category>mobile</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Turned Habits Into RPG Stats. Then the Numbers Went Past 100.</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Thu, 23 Jul 2026 08:05:17 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/i-turned-habits-into-rpg-stats-then-the-numbers-went-past-100-mha</link>
      <guid>https://dev.to/nabeelbaghoor/i-turned-habits-into-rpg-stats-then-the-numbers-went-past-100-mha</guid>
      <description>&lt;p&gt;I built a self-improvement app that turns your habits into RPG stats. You do pushups, you meditate, you go to bed earlier, and the app moves six attributes on a 0 to 100 scale: Strength, Wisdom, Discipline, Confidence, Morality, and an Overall that sits on top. It is called Lifemaxxing AI, I build it with my partner Benjamin Siegel, and it is a Flutter app.&lt;/p&gt;

&lt;p&gt;The fun part was never the UI. The fun part, and the part that quietly ate a week, was the scoring math. Because the moment a real person touches a scoring system, it breaks in ways that look obvious in hindsight and are invisible while you are writing it.&lt;/p&gt;

&lt;p&gt;Here is the story of how the numbers went past 100, why that is a harder problem than it sounds, and the one architecture decision that saved me anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The good decision: game balance is data, not code
&lt;/h2&gt;

&lt;p&gt;Before I get to what broke, the thing I got right.&lt;/p&gt;

&lt;p&gt;Every task in the app lives in a single &lt;code&gt;task_config.json&lt;/code&gt; file. Not in Dart, not hardcoded in a service, not scattered across screens. One file. A task looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pushUps"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"taskType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"initialAmounts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"initialFrequency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"amountChangeFrequency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"amountChangeRate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"conditional:if amount &amp;lt; 10 then +2; if amount between 10-20 then +5; if amount &amp;gt; 20 then +10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxMinAmount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxFrequency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ratingCategories"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"STRENGTH"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That config carries the whole game. &lt;code&gt;initialAmounts&lt;/code&gt; is where a user starts based on their onboarding answer. &lt;code&gt;amountChangeRate&lt;/code&gt; is how the difficulty ramps, and yes, it can be a conditional string that the engine parses, so pushups jump by 2 when you are weak and by 10 once you are strong. &lt;code&gt;maxMinAmount&lt;/code&gt; is the 100 percent target, 150 pushups here. And &lt;code&gt;ratingCategories&lt;/code&gt; is the interesting one: it maps a task to the attributes it feeds and how strongly.&lt;/p&gt;

&lt;p&gt;Five task types share this shape. &lt;code&gt;count&lt;/code&gt; for pushups and pages. &lt;code&gt;time&lt;/code&gt; for clock targets like bedtime, stored as minutes from midnight so 1410 is 11:30pm and the target 1260 is 9pm. &lt;code&gt;duration&lt;/code&gt; for things like meditation length. &lt;code&gt;limit&lt;/code&gt; for social media caps. &lt;code&gt;boolean&lt;/code&gt; for cold showers and the no-porn kind of yes or no habit.&lt;/p&gt;

&lt;p&gt;The payoff is that tuning game balance is a data change, not a deploy. When 50 pushups by week three felt brutal, I did not touch a class. I edited a number. That decision is the only reason the next part did not sink the whole feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke: additive scoring is a trap
&lt;/h2&gt;

&lt;p&gt;The first scoring model was the obvious one. Take each task, normalize its current amount to a 0 to 100 percent based on how far the user has progressed toward &lt;code&gt;maxMinAmount&lt;/code&gt;, multiply by the task's weight for an attribute, and add it to a base rating.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;boost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;normalizedScore&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;attributeTotals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;attribute&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;boost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// keeps adding&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It demos beautifully. It falls apart the instant a real user has more than one habit.&lt;/p&gt;

&lt;p&gt;Problem one, weights do not sum to 1. Strength is fed by pushups at 0.75, running at 0.75, cold showers at 0.25, and workout at 0.5. Add those up and Strength has a total possible weight of 2.25. There is no rule anywhere saying an attribute's inputs should sum to a whole. I never wrote one, so the system never had one.&lt;/p&gt;

&lt;p&gt;Problem two, unbounded accumulation. Because I was adding, a balanced user doing pushups at 50 percent and running at 66 percent lands at 30 plus 37.5 plus 49.5, which is 117 Strength. On a 0 to 100 scale. A specialist who maxes pushups alone gets 105. The number is not just wrong, it is meaningless, because 105 and 340 are equally "off the top of the bar."&lt;/p&gt;

&lt;p&gt;Problem three, daily spam. Nothing stopped a user from completing the same task three times in a day and adding the points three times. The scoring rewarded farming, not living better, which is the exact opposite of the product.&lt;/p&gt;

&lt;p&gt;None of these are exotic. Every one of them is what happens when you model a bounded, competitive score as a running sum. The failure is not in the code, the code does exactly what it says. The failure is that "add up the contributions" is the wrong mental model for a stat that has to mean something at 90.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reframe: normalizing a game score is the actual product
&lt;/h2&gt;

&lt;p&gt;The thing I under-weighted going in: in a gamified app, the scoring math is not plumbing behind the feature. It is the feature. A Strength of 78 has to feel earned and comparable to someone else's 78, or the entire loop of "grind, watch the bar move, feel it" is hollow. If any combination of habits can push a bar to 117, the bar means nothing, and a meaningless bar is a dead app.&lt;/p&gt;

&lt;p&gt;So the rebuild started from constraints, not code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every attribute stays inside 0 to 100, always, no matter how many tasks feed it.&lt;/li&gt;
&lt;li&gt;Multiple tasks hitting the same attribute have to share it, so weights get normalized per attribute rather than trusted to sum correctly.&lt;/li&gt;
&lt;li&gt;Frequency and consistency matter, not just raw amount. Doing a task every day should beat doing it hard once, because that is what actually changes a life and what the app is supposed to reward.&lt;/li&gt;
&lt;li&gt;The system has to resist gaming. Completing the same task ten times in a day cannot ten-times the reward. Contribution gets capped per task per day.&lt;/li&gt;
&lt;li&gt;The scale has to feel real. 30 to 50 is a beginner, 50 to 75 is intermediate, 90 plus should only be reachable through genuine long-term consistency, not a single maxed number.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And critically, all of that stays configurable. The &lt;code&gt;ratingCategories&lt;/code&gt; weights, the base ratings, the caps, they live in the same JSON. The model changed. The principle that game balance is data survived, and that is what let me rip out the scoring core without touching the twenty screens that read from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell past me
&lt;/h2&gt;

&lt;p&gt;Two things.&lt;/p&gt;

&lt;p&gt;First, config-driven design is worth doing early, but it does not save you from a wrong model. I had a beautifully data-driven engine computing numbers that were nonsense. Flexibility in the wrong equation is just faster nonsense. The JSON let me fix it quickly, but it could not tell me the equation was broken. Only a real user with four habits could.&lt;/p&gt;

&lt;p&gt;Second, if a number is shown to a user as a bounded score, treat the bound as a hard invariant from line one, not a thing you clamp at the end. &lt;code&gt;attributeTotals[attribute] += boost&lt;/code&gt; has no idea 100 exists. I should have designed the whole calculation so that exceeding 100 was structurally impossible, not caught with a clamp after the fact. A clamp hides a broken distribution. It does not fix it.&lt;/p&gt;

&lt;p&gt;The tracking, the plans, the RPG bars, all of that is live at &lt;a href="https://lifemaxxingai.com/" rel="noopener noreferrer"&gt;lifemaxxingai.com&lt;/a&gt;. The scoring engine underneath is on its second model and honestly will probably see a third, because that is what happens when the math is the product. You do not get it right once. You get it a little less wrong every time a real person shows you where it bends.&lt;/p&gt;

&lt;p&gt;If you are building anything that turns behavior into a score, do the boring thing early. Write down what the number is allowed to be before you write the line that produces it.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>architecture</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>One C# Core, Three Platforms: What Shipping a Background Networking SDK Taught Me</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Wed, 22 Jul 2026 11:26:16 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/one-c-core-three-platforms-what-shipping-a-background-networking-sdk-taught-me-5b2a</link>
      <guid>https://dev.to/nabeelbaghoor/one-c-core-three-platforms-what-shipping-a-background-networking-sdk-taught-me-5b2a</guid>
      <description>&lt;p&gt;Most "cross-platform" war stories are really UI stories: one framework draws the same button on two phones and everyone calls it a day. The one I want to tell is not that. At Geonode I built the Repocket C# SDK, a single networking core that had to run on Windows, Android and iOS and carry all the logic for internet sharing on every one of them. One codebase, three very different runtimes, and a piece of software that lives in the background of someone else's device and must never misbehave.&lt;/p&gt;

&lt;p&gt;There is no screen to hide behind here. Repocket is an internet-sharing product, so the SDK is not a view, it is a long-lived networking client that sits quietly on a user's machine and routes traffic. If the shared core is wrong, it is wrong on Windows, Android and iOS at once, and it is wrong in the background where nobody is watching. That raises the bar for every decision, and it is why the lessons stuck. Here is what shipping one C# SDK to three platforms actually taught me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why C# for a job like this
&lt;/h2&gt;

&lt;p&gt;The instinct for cross-platform native code is usually C or Rust with thin bindings. We went with C#, and for this product it was the right call. A single managed core meant the networking logic, the part that had to be identical everywhere, lived in one place with one set of tests. On Windows it ran natively. On Android and iOS it ran through the .NET mobile runtime, so the same compiled logic drove all three platforms instead of three hand-ported copies drifting apart over time.&lt;/p&gt;

&lt;p&gt;The rule I kept coming back to was simple: &lt;strong&gt;share the logic, never the platform.&lt;/strong&gt; Anything that was pure behavior, how a connection is established, retried, throttled, torn down and reported, belonged in the shared C# core. Anything that touched the operating system, background execution, permissions, network-state callbacks, power management, belonged in a thin native layer per platform. The moment you let OS-specific assumptions leak into the shared core, you have three subtly broken platforms instead of one clean one.&lt;/p&gt;

&lt;h3&gt;
  
  
  The shared core, kept ruthlessly pure
&lt;/h3&gt;

&lt;p&gt;The discipline that paid off most was keeping the core free of platform knowledge. The core does not know what a Windows service is or what an Android foreground notification is. It exposes a small surface: start, stop, current state, and events. Each platform's native shim is responsible for translating the messy reality of that OS into those few clean calls.&lt;/p&gt;

&lt;p&gt;That boundary is worth obsessing over, because it is exactly where cross-platform projects rot. Every time you are tempted to write &lt;code&gt;if (Android) { ... }&lt;/code&gt; inside shared code, you are trading a day now for a month later. I would rather add one more method to the native interface than one more platform branch to the core. Years on, building AI agents instead of SDKs, I keep the same instinct: the business logic lives in a separate automation layer, not baked into whatever happens to be in front of the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  The background is the hard part
&lt;/h2&gt;

&lt;p&gt;A foreground app has it easy. It is on screen, the user expects it to use resources, and the OS mostly leaves it alone. A background networking client is the opposite: every mobile OS is actively trying to suspend it, throttle it, or kill it to save battery, and it has to keep working anyway, invisibly, for hours.&lt;/p&gt;

&lt;p&gt;This is where the three platforms stopped rhyming and each demanded its own answer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows&lt;/strong&gt; was the friendliest. A long-running process with real background freedom, so the native layer there was mostly about lifecycle, clean startup and clean shutdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android&lt;/strong&gt; wanted the work justified. Background networking has to survive Doze, app standby and aggressive OEM battery managers, which means foreground-service semantics and being an honest citizen about what you are doing and why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS&lt;/strong&gt; was the strictest by a wide margin. The system reclaims background time hard, so the native layer had to work &lt;em&gt;within&lt;/em&gt; iOS's rules for background networking rather than fight them, and assume it could be suspended at any moment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson that generalized far beyond this SDK: &lt;strong&gt;design for being paused, not just for running.&lt;/strong&gt; The core could not assume it would execute uninterrupted. It had to hold enough state to be suspended and resumed cleanly, reconcile what changed while it was asleep, and never leave a half-open connection or corrupt state behind when the OS pulled the rug out. That is the same muscle I later needed in AR work, where you assume the overlay will be wrong at the worst possible moment and make it a non-event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Networking that has to be a good guest
&lt;/h2&gt;

&lt;p&gt;Because the SDK routes traffic on someone else's device, being well-behaved was not a nice-to-have, it was the product. Bad networking behavior on a user's machine is not a bug ticket, it is an uninstall. So the core treated the host device's resources as borrowed, not owned.&lt;/p&gt;

&lt;p&gt;That shaped the connection logic directly. Retries had to back off instead of hammering. Failures had to be contained so one bad connection never cascaded. Resource use had to stay predictable so the SDK never became the reason a user's device felt slow or hot. The networking layer's real job was less "move packets fast" and more "move packets without ever being noticed," which is a harder and more interesting constraint than raw throughput.&lt;/p&gt;

&lt;p&gt;Getting that right once, in the shared core, meant every platform inherited the good behavior for free. That is the compounding payoff of the share-the-logic rule: solve the strict version of a problem in one place and all three runtimes get the fix at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI that builds on every platform, every time
&lt;/h2&gt;

&lt;p&gt;A cross-platform SDK is only as trustworthy as its worst-supported target, and the only way to keep three targets honest is to build all three on every change. So I set up Unity and Windows CI/CD pipelines so the whole thing compiled and packaged automatically rather than depending on whatever happened to be installed on my machine that week.&lt;/p&gt;

&lt;p&gt;The value is not glamorous but it is enormous: it makes "it works on my machine" impossible to hide behind. If a change compiled on Windows but broke the mobile build, CI caught it the same hour, not three weeks later when someone tried to cut a release. For a background SDK that ships inside other apps, a broken build is not a local annoyance, it is a broken client on real users' devices, so the pipeline was as much a part of the product as the code.&lt;/p&gt;

&lt;p&gt;I built a React Native VPN alongside the SDK work too, another exercise in the same theme: a thin cross-platform surface over deeply platform-specific system networking. VPN entitlements, tunnel lifecycle and OS network extensions are about as platform-specific as software gets, and the same discipline applied. Keep the shared logic shared, wrap the OS-specific parts in the thinnest possible native layer, and let the platform be platform-specific where it insists on being.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a cross-platform SDK taught me
&lt;/h2&gt;

&lt;p&gt;Years later I have moved from .NET SDKs and Unity into AI agents and my own products. But the Repocket work left instincts I use in almost everything I ship now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Share the logic, never the platform.&lt;/strong&gt; One clean core plus thin native shims beats three drifting copies. The boundary is the whole game.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design for being paused.&lt;/strong&gt; Code that can be suspended, killed and resumed without corruption is more robust everywhere, not just on iOS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be a good guest.&lt;/strong&gt; Software that runs where a user is not watching has to treat their resources as borrowed. Restraint is a feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build every target, every time.&lt;/strong&gt; CI across all platforms turns "works on my machine" from a hope into a guarantee.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tools change. I have been paid for C#, Unity, mobile SDKs, and now LLM agents, and I expect that list to keep growing. What carries across all of it is knowing exactly what a piece of software cannot afford to get wrong and building the boundary around that first. A background SDK on three platforms taught me that lesson in a place where there was no interface to hide the mistakes behind, and I have built cleaner ever since.&lt;/p&gt;

&lt;p&gt;If you want more of how I think about this kind of work, I write it all up at &lt;a href="https://nabeelbaghoor.com" rel="noopener noreferrer"&gt;nabeelbaghoor.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>csharp</category>
      <category>dotnet</category>
      <category>mobile</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Build vs Buy, for Engineers Who Always Want to Build It</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Mon, 20 Jul 2026 09:23:11 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/build-vs-buy-for-engineers-who-always-want-to-build-it-1nhn</link>
      <guid>https://dev.to/nabeelbaghoor/build-vs-buy-for-engineers-who-always-want-to-build-it-1nhn</guid>
      <description>&lt;p&gt;As an engineer, my default answer to almost any problem is "I'll just build it." Give me a weekend and a coffee and I will happily reimplement something that already exists on npm, because building it myself feels like control and buying it feels like admitting defeat.&lt;/p&gt;

&lt;p&gt;That instinct has cost me real time. It has also, in a handful of cases, been exactly the right call. After shipping 40+ products with my studio, most of them for businesses deciding whether to buy an off-the-shelf tool or commission a custom build, I have a much more honest framework than "build is cool, buying is giving up." Here is how I actually think about it now, including the part where AI coding agents quietly moved the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Horizontal vs vertical, in plain terms
&lt;/h2&gt;

&lt;p&gt;The build-vs-buy question usually shows up as horizontal SaaS versus vertical SaaS.&lt;/p&gt;

&lt;p&gt;Horizontal SaaS solves one job for everyone: a CRM, a spreadsheet, a generic accounting package. Vertical SaaS solves the whole job for one kind of business, in that industry's own language, with its real objects and rules baked in.&lt;/p&gt;

&lt;p&gt;The difference is not cosmetic. A generic accounting tool knows about invoices and ledgers. An accounting platform built for the fuel trade knows about tankers, suppliers, fuel loads and the specific way money moves in that business. We built one of those, Fyuel, because the people running a fuel business were otherwise stitching that picture together by hand across tools that were never designed for them. Customers, suppliers, tankers, ledgers, banks and reports finally live in one real-time system. Vertical software wins by removing the stitching.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signals you have outgrown off-the-shelf
&lt;/h2&gt;

&lt;p&gt;You rarely need a study to know you have hit this wall. As a developer working with a client, these are the symptoms I listen for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The team lives in exports.&lt;/strong&gt; People pull data out of one tool, reshape it in a spreadsheet, and feed it into another. That spreadsheet is unowned, unmaintained software running the business.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The workaround became the process.&lt;/strong&gt; A manual step that started as a stopgap is now how the work gets done, and onboarding a new hire means teaching them the workaround, not the tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real process does not fit the software's model.&lt;/strong&gt; They are paying for a product and then fighting it, forcing their workflow into fields and states that do not match how the job actually runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data is scattered and never current.&lt;/strong&gt; The number that matters lives in three places, none of them agree, and by the time someone reconciles them it is stale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The thing they do best is the thing no tool supports.&lt;/strong&gt; When the edge is a process competitors do not have, no off-the-shelf product will have it either, because it is built for the average of the whole industry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One or two of these is a configuration problem. All of them together is a signal that the generic tool has become the ceiling on how well the business can operate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off (buying wins more than engineers admit)
&lt;/h2&gt;

&lt;p&gt;Here is the part my build-it instinct does not want to hear: buying wins more often than founders, and engineers, expect. If a mature product already fits the process closely, buy it. Someone else maintains it, fixes it and improves it, and you get value on day one instead of after a build. I talk clients out of custom work regularly, the same way I would tell you not to hand-roll auth when a battle-tested library exists.&lt;/p&gt;

&lt;p&gt;Building earns its cost in a narrower but very real set of cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The process is a competitive advantage.&lt;/strong&gt; If how they operate is part of why they win, encoding it into software they own compounds that edge instead of flattening it to the industry average.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The integrations are the point.&lt;/strong&gt; When the value is in making systems that do not natively talk to each other work together in real time, that seam is exactly what off-the-shelf tools leave to you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They would be paying to fight the tool forever.&lt;/strong&gt; If the workarounds are permanent and growing, the "cheap" subscription has a large hidden cost in wasted hours that a fitted system removes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They need to own the roadmap.&lt;/strong&gt; Buying means waiting on someone else's priorities. Building means the next feature is the one their business needs next.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mistake in both directions is the same: deciding on price tag alone. A subscription that forces ten hours of manual work a week is not cheap, and a custom build that reinvents a solved problem is not an investment. Weigh total cost, including the labor the tool creates or removes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom does not always mean a full platform
&lt;/h2&gt;

&lt;p&gt;This is the reframe that helped me most as an engineer. "Custom software" is not synonymous with "big SaaS system." Some of the most valuable vertical work is narrow and deep.&lt;/p&gt;

&lt;p&gt;Raqts, the first racquet-sport wall experience we built, needed computer vision and IoT to let a physical wall respond to how you hit the ball, something no generic product could ever provide. Geonode's cross-platform SDKs let users earn through bandwidth sharing, a capability delivered as infrastructure rather than an app. Neither is a sprawling platform. Each is one specialized capability that no off-the-shelf tool owns.&lt;/p&gt;

&lt;p&gt;So scope to the specific thing the business actually needs, not to a category. The right custom build is often a single sharp capability bolted onto tools you bought for everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  How AI moved the line
&lt;/h2&gt;

&lt;p&gt;This is the genuinely new part, and it is why I am writing this for a dev audience rather than a buyer one.&lt;/p&gt;

&lt;p&gt;For years, "just buy it" won by default because building was slow and expensive enough that only large companies could justify custom software. That gap has narrowed sharply. AI coding agents now handle the mechanical majority of a build: the scaffolding, the integration glue, the tests, the refactors, while I direct the architecture and review everything that ships. That is how we compress months of work into days without the code turning fragile.&lt;/p&gt;

&lt;p&gt;The practical effect is that the threshold where building beats buying has dropped. Custom software that was only worth it for enterprises a few years ago can now make sense for a mid-sized business, because the cost of building fell faster than the cost of the problem it solves.&lt;/p&gt;

&lt;p&gt;That does not mean build everything. It means the honest comparison is no longer "cheap subscription versus expensive custom project." It is "a tool you fight forever versus a fitted system that now costs far less to build than it used to." If you are the engineer in the room, that is the version of the trade-off to put in front of the decision maker.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I decide now
&lt;/h2&gt;

&lt;p&gt;Reverse the usual order. Do not start from a price tag; start from the fit.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Count the workaround hours.&lt;/strong&gt; Add up the real weekly labor the current tools create. That is the true cost of buying, and it is usually invisible on the invoice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name the edge.&lt;/strong&gt; If the competitive advantage is a process, lean toward owning it in software. If it is not, a bought tool is probably fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map the seams.&lt;/strong&gt; If the pain is data scattered across systems that will not talk, that is a classic build-to-consolidate case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match the reliability bar to the stakes.&lt;/strong&gt; A system that runs money or operations needs real hardening; a low-stakes internal tool does not. Pay for the bar you actually need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope the smallest version first.&lt;/strong&gt; Whether you build or buy, prove the core loop before committing to the whole platform.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My build-it instinct is still there. I have just learned to run it through those five questions before I open the editor. Most of the time it survives them. Sometimes it does not, and the most senior thing I can do is say "buy the tool" and move on to the problem that actually needs me.&lt;/p&gt;

&lt;p&gt;If you want the fuller version with the client examples written up, I put it on my studio blog at &lt;a href="https://nullstud.io/blog/vertical-saas-build-vs-buy" rel="noopener noreferrer"&gt;nullstud.io&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>architecture</category>
      <category>ai</category>
      <category>startup</category>
    </item>
    <item>
      <title>One Scrape Per Category, Not Per User: How I Kept a Job-Alert SaaS From Going Broke</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Sun, 19 Jul 2026 13:26:33 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/one-scrape-per-category-not-per-user-how-i-kept-a-job-alert-saas-from-going-broke-3e19</link>
      <guid>https://dev.to/nabeelbaghoor/one-scrape-per-category-not-per-user-how-i-kept-a-job-alert-saas-from-going-broke-3e19</guid>
      <description>&lt;p&gt;I run a small job-alert product called Upwork Scout. Users set filters, describe themselves once, and it emails them only the Upwork jobs that actually fit their profile. Simple pitch. The interesting part is a constraint I gave myself on day one: the scraping cost has to stay roughly flat no matter how many people sign up. Here is how I built it so that adding a user costs me almost nothing, plus a few of the boring engineering decisions that ended up mattering more than the flashy ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap most alert tools fall into
&lt;/h2&gt;

&lt;p&gt;The obvious way to build a job-alert tool is to loop over your users and, for each one, run their search against the source and email them the results. It works great with ten users. It quietly bankrupts you at ten thousand, because your scraping bill grows linearly with signups. Every new user becomes a new recurring cost, and scraping is by far the most expensive thing the app does.&lt;/p&gt;

&lt;p&gt;I did not want a business where growth is a liability. So before I wrote the scan engine I flipped the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  One scrape per category, not per user
&lt;/h2&gt;

&lt;p&gt;The realization that made the whole thing viable: most users are not that unique. Two freelancers who both want "AI automation" work are searching for nearly the same jobs. So instead of scraping per user, I scrape per category.&lt;/p&gt;

&lt;p&gt;A category is a single Upwork boolean search defined in code. Every cycle, the engine looks at which categories have at least one active subscriber, scrapes only those, stores the fresh jobs once in Firestore, then matches every user against that shared job stream in memory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;          category A query
cron  --&amp;gt; category B query --&amp;gt; dedupe + store new jobs --&amp;gt; match each user --&amp;gt; email
          category C query          (Firestore)             (in-memory)      (instant/digest)
   (only categories with at least one active subscriber are scanned)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The effect is that my scraping cost tracks the number of active categories, not the number of users. There is a hard cap on categories, so there is a hard ceiling on spend. Two hundred users subscribed to the same three categories cost me exactly the same to scan as two users subscribed to those three categories. In-memory matching is basically free by comparison, so the expensive part of the pipeline stopped scaling with growth.&lt;/p&gt;

&lt;p&gt;That one inversion is the difference between a tool I can afford to run and one I would have to shut off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two-stage matching: cheap filter, then a paid opinion
&lt;/h2&gt;

&lt;p&gt;Matching a user to a job happens in two passes, and the ordering is deliberate because the second pass costs real money.&lt;/p&gt;

&lt;p&gt;The first pass is a pure, deterministic function. Does this job satisfy the user's hard filters: category or keyword hit, budget range, experience level, client spend, client rating, country, payment-verified, and a dozen more. No network, no model, just booleans. It throws out most jobs for free.&lt;/p&gt;

&lt;p&gt;Only the survivors reach the second pass, which is the AI scoring. I send the job and the user's self-description to Claude and get back a 0 to 100 fit score and a one-line reason. I run it on Haiku by default because it costs about a tenth of a cent per score, and I can point it at a bigger model with one env var if match quality ever matters more than cost on a given day.&lt;/p&gt;

&lt;p&gt;The important discipline here is to never let the expensive pass do work the cheap pass could have done. If the deterministic filter can reject a job, the model never sees it. The model is there for the fuzzy judgment a boolean cannot make, "is this actually the kind of work this person wants," not for anything a comparison operator already answered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reject only on positive evidence
&lt;/h2&gt;

&lt;p&gt;This is the detail I am most quietly proud of, and it came from watching real data. Upwork does not always expose every field. Older records were missing things, and sometimes a field is just hidden on a given posting. My first version treated missing data as a failed filter, which meant a perfectly good job with a blank client-spend field got silently dropped. Users were missing work and I could not see why.&lt;/p&gt;

&lt;p&gt;So I made the policy explicit and consistent across every filter: a filter only rejects a job on positive evidence. If the data needed to fail a check is missing, the job passes and the interface flags the value as unknown rather than pretending it failed. The user decides what to do with an unknown, instead of the system deciding for them by omission. Silent false negatives are the worst bug class in an alert tool, because nobody complains about the email they never got.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching the model's opinions
&lt;/h2&gt;

&lt;p&gt;The other cost lever is that a job is never scored twice for the same user. Every AI verdict gets cached, keyed by user and job id, with the score and the reason. If a job resurfaces in a later cycle, the cached verdict is reused. Combined with a platform-wide daily fetch budget that hard-stops scraping once it is hit, the two caps mean the app cannot run away with my money even on a busy day. I would rather deliver slightly late than wake up to a surprise bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring decisions that carried the most weight
&lt;/h2&gt;

&lt;p&gt;A few unglamorous choices did more for reliability than the clever ones:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No password database.&lt;/strong&gt; Auth is passwordless magic links, and sessions are signed JWT cookies. There is no password table to leak, no reset flow to build, no hashing to get wrong. The whole surface I would have had to secure simply does not exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A self-locking cron endpoint.&lt;/strong&gt; The engine is one route you hit on a schedule from an external pinger. It acquires a short self-expiring lock before it runs, so if two pings overlap, the second one does nothing instead of double-charging me for scraping and double-emailing users. The endpoint is idempotent by design, which means I stopped worrying about exactly-once scheduling and just let the lock handle it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lazy secret reads.&lt;/strong&gt; Every secret is read at request time, not at import time, so the build never needs any of them. That sounds trivial until you have a CI build fail because a key was not present at compile time for code that only needs it at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  What building it taught me
&lt;/h2&gt;

&lt;p&gt;The lesson I keep relearning is that the architecture decision you make before the first real user is worth more than any optimization you make after. I could not have refactored my way from per-user scraping to per-category scraping once the data model assumed one search per person. It had to be the shape of the thing from the start.&lt;/p&gt;

&lt;p&gt;The second lesson is that in a tool whose entire job is to notify you, the failure you cannot see is the one that kills trust. The reject-on-positive-evidence rule and the "flag the unknown" interface came from taking that seriously. An alert product lives or dies on the emails it does not wrongly withhold.&lt;/p&gt;

&lt;p&gt;It is built on Next.js 16 on Vercel, Firestore, Apify for the scraping, Resend for email, and Claude for the scoring. If you freelance on Upwork and want to see the matching in action, it is live at &lt;a href="https://upwork-scout.com" rel="noopener noreferrer"&gt;upwork-scout.com&lt;/a&gt;. And if you are building anything that scrapes or calls a paid API per user, ask yourself the one question that saved this project: what can I compute once and share, instead of computing per user and paying for it forever.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
      <category>nextjs</category>
      <category>architecture</category>
    </item>
    <item>
      <title>From ICPC to US Clients: 12 Freelancing Lessons I Wish I'd Known as an Engineer</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Sat, 18 Jul 2026 06:42:46 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/from-icpc-to-us-clients-12-freelancing-lessons-i-wish-id-known-as-an-engineer-50mo</link>
      <guid>https://dev.to/nabeelbaghoor/from-icpc-to-us-clients-12-freelancing-lessons-i-wish-id-known-as-an-engineer-50mo</guid>
      <description>&lt;p&gt;Seven years ago I was a competitive programmer in Lahore, grinding Codeforces problems at 2am and treating a good contest rating like the only scoreboard that mattered. Since then the scoreboard changed a lot: ICPC World Finals, AR systems that ended up in front of the U.S. Fire Administration, CTO roles at US startups, my own studio with 40+ shipped products, and a couple of apps with real users.&lt;/p&gt;

&lt;p&gt;I get asked "how do you get US clients from Pakistan?" almost every week, usually by other developers. The honest answer is that there was no single trick. But looking back, there were a handful of repeatable moves that actually moved my trajectory, and almost all of them are things any engineer can copy. This is the version I wish someone had handed me in 2019.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Enter through the door with no queue
&lt;/h2&gt;

&lt;p&gt;My first real freelance niche was not web development. It was Unity game development, because in 2019 every freelancer on earth was a web developer and almost none of them could ship a game. Small pond, visible skill, fast wins. The same logic later pulled me into AR, and then into voice AI in its first year.&lt;/p&gt;

&lt;p&gt;The pattern is simple: arrive where demand exists but supply has not caught up. As an engineer you are trained to optimize the thing in front of you, but the higher-leverage move is picking a different thing entirely. In 2026 that door is AI agents. The window is open, and like every window before it, it is already starting to close.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Credentials open doors, shipping keeps them open
&lt;/h2&gt;

&lt;p&gt;ICPC World Finals never directly won me a contract. Not once. But it changed first conversations, because "national champion" survives a recruiter skimming your profile in four seconds. That is the entire job of a credential. After the door opens, only delivery matters.&lt;/p&gt;

&lt;p&gt;So collect one or two undeniable markers, then stop collecting. I have watched talented people chase their fifth certification instead of shipping their first real project. The certification is a key, not a house.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Speed is a feature clients can feel
&lt;/h2&gt;

&lt;p&gt;Every client relationship I have kept started the same way: an early deliverable that arrived embarrassingly fast. First impressions on speed anchor everything after. If the first thing you send lands two days early, the client quietly recalibrates what they expect from you for the rest of the engagement, and it works in your favor.&lt;/p&gt;

&lt;p&gt;This is true before the contract too. On Upwork, being in the first handful of proposals decides outcomes far more than proposal eloquence does. I cared about that edge enough that I eventually built my own tool to catch matching jobs within minutes of posting. Speed is not just about typing fast. It is about removing the latency between "client has a need" and "you have responded."&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Your timezone is a weapon, not an excuse
&lt;/h2&gt;

&lt;p&gt;Lahore evenings are US mornings. I answer while US-based competitors sleep, ship overnight fixes that greet clients at breakfast, and take late calls that would be nobody's business hours. For years I thought the offset was the thing holding me back. Then I reframed it, and clients started listing it as a reason they hired me. "I go to sleep, you keep working, I wake up to progress" is a genuinely great pitch.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. One niche, publicly owned
&lt;/h2&gt;

&lt;p&gt;"Full-stack developer" is a commodity search result. "AI voice agents on Retell and GoHighLevel for US service businesses" is a person clients ask for by name. Niching down felt like closing doors. It opened much bigger ones. Most of my studio's inbound now comes from being known for one specific thing rather than being available for everything.&lt;/p&gt;

&lt;p&gt;The fear here is real and I felt it: what if I pick the wrong niche? You probably will, at first. That is fine. A niche is a bet you can re-place. Being un-findable is the actual risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Turn every contract into an artifact
&lt;/h2&gt;

&lt;p&gt;A finished project with no public trace is a wasted asset. One contract can become a case study, a portfolio card, a client review, and a technical write-up. Same work, four assets, and the assets keep working while you sleep.&lt;/p&gt;

&lt;p&gt;My very first DEV article, about becoming a freelance game developer in Pakistan, still sends people to me years after I wrote it. That is the strange magic of writing things down. Code you ship gets buried in a private repo. A public write-up compounds. If you are an engineer who "doesn't write," this is the highest-ROI habit you are skipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Raise rates at the moment of demonstrated value
&lt;/h2&gt;

&lt;p&gt;Never mid-project, always at the natural boundary right after a visible win. Every meaningful rate jump I have made followed a shipped milestone and was framed as scope maturity, not inflation. "Now that we know what this actually takes" lands very differently than "I've decided I'm worth more."&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Fire the bottom of your client list
&lt;/h2&gt;

&lt;p&gt;The 20% of clients who generate 80% of your stress also block the capacity for better ones. The scariest professional moves I have made were endings, and each one made room for something bigger within months. Better still, screen before you sign so you rarely have to fire. Reading a client carefully before taking the job has saved me more grief than any contract clause ever has.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. The founder-type freelancer wins the CTO work
&lt;/h2&gt;

&lt;p&gt;Clients do not hire fractional CTOs from a "CTO store." They promote the freelancer who already acts like an owner: raises risks early, says no with reasons, thinks past the literal ticket. Three companies eventually made me CTO, and none of those started as CTO engagements. They started as normal contracts where I behaved like I had equity in the outcome.&lt;/p&gt;

&lt;p&gt;This is a mindset most engineers can adopt tomorrow. Stop asking only "what did you ask me to build" and start asking "what are you actually trying to achieve, and is this the right way to get there."&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Build products in parallel, even tiny ones
&lt;/h2&gt;

&lt;p&gt;LectureNotes AI (15M organic Instagram views, around 10k users), Lifemaxxing AI, VoiceDash, Upwork Scout. Beyond any revenue they generate, products are proof-of-agency. Client work pays you today. Products change what clients believe you are capable of, which changes what they pay you tomorrow.&lt;/p&gt;

&lt;p&gt;You do not need a startup. You need one small thing that exists in the world with your name on it. A CLI tool, a Chrome extension, a niche SaaS. It reframes you from "person who does tasks" to "person who ships things."&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Community is slow-release luck
&lt;/h2&gt;

&lt;p&gt;Running the IEEE chapter at my university, mentoring, appearing on a local podcast, writing publicly. None of it had immediate ROI. All of it produced opportunities years later from directions I could not have predicted. You are planting flags in ground you will walk past again someday. Some of them grow.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. The stack changes, the meta-skill does not
&lt;/h2&gt;

&lt;p&gt;I have been paid for C++, Unity, .NET, React, AR headsets, and now LLM agents. Nothing I mastered in 2019 is my main income in 2026. If I had bet my identity on a single stack, I would have been obsolete twice over by now.&lt;/p&gt;

&lt;p&gt;The durable skill is learning the next thing fast while shipping the current thing well. Which is, conveniently, the exact skill competitive programming drills into you: read an unfamiliar problem, build a working mental model quickly, ship a correct solution under pressure, repeat. The domains changed. The loop never did.&lt;/p&gt;




&lt;p&gt;If there is one thing to take from this, it is that none of these are talents. They are choices, and most of them are available to you this week. Pick the niche. Ship the artifact. Answer faster than everyone else. Write the thing down.&lt;/p&gt;

&lt;p&gt;I am still running this playbook, currently in the AI-agent space through my studio &lt;a href="https://nullstud.io" rel="noopener noreferrer"&gt;Null Studio&lt;/a&gt;. If you are an engineer thinking about going independent, I hope some of this saves you a year or two of guessing. It would have saved me a few.&lt;/p&gt;

</description>
      <category>freelancing</category>
      <category>career</category>
      <category>programming</category>
      <category>devjournal</category>
    </item>
  </channel>
</rss>
