<?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 Voice Agent Needs Three Exits, Not One</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Fri, 21 Aug 2026 21:44:16 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/your-voice-agent-needs-three-exits-not-one-473h</link>
      <guid>https://dev.to/nabeelbaghoor/your-voice-agent-needs-three-exits-not-one-473h</guid>
      <description>&lt;p&gt;Most voice agent demos optimise the happy path. Caller asks for a booking, agent books it, everyone claps. I have shipped these into hospitals, vet clinics, garages and brokerages, and the happy path is not where they live or die. They die on the calls that fall off the edge of the script.&lt;/p&gt;

&lt;p&gt;An agent that handles ninety percent of calls and drops the other ten is not a ninety percent success. The ten it drops are the calls that mattered most: the emergency, the angry customer, the deal that was ready to close. The business judges the whole system on those, and they are right to.&lt;/p&gt;

&lt;p&gt;If you write software you already know this shape. It is error handling. We all know the error paths are the real product surface and we all still write them last. Voice agents punish you faster, because the failure is audible and a human is listening to it live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three exits, not one
&lt;/h2&gt;

&lt;p&gt;"Transfer to a human" gets treated as a single feature, one boolean in a config. In production it is three different code paths, and conflating them is what makes an agent feel broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exit one: transfer the live call.&lt;/strong&gt; The caller stays on the line and a person picks up. Right when the situation is urgent, when the caller has already been through one loop of misunderstanding, or when the business loses real money by making this person call back. It is also the exit with the most ways to fail, because it depends on a specific human being available this second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exit two: take a structured message and escalate.&lt;/strong&gt; The agent captures the details properly, ends the call cleanly, and fires an alert into whatever the team actually watches. This is the correct exit far more often than clients expect, especially outside business hours, and it is the one that gets skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exit three: refuse and route.&lt;/strong&gt; The agent declines the topic entirely, says so plainly, and points at the right channel. For a medical practice that is clinical advice. For a brokerage it is anything touching price negotiation or fair housing. The agent does not need a graceful answer here. It needs a firm one.&lt;/p&gt;

&lt;p&gt;Once you have three exits you stop trying to build an agent that always knows the answer, which is a losing game, and start building one that always knows where the call goes next. Much smaller problem, and actually solvable.&lt;/p&gt;

&lt;h2&gt;
  
  
  I write the exit conditions before the script
&lt;/h2&gt;

&lt;p&gt;My first question to a client is never what the agent should say. It is: which calls, if this thing handles them badly, would make you turn it off tomorrow? The answers come back short and specific, and they are the escalation spec:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The vet clinic said any caller describing a collapsed, bleeding or poisoned animal. No triage, no questions, straight to a person.&lt;/li&gt;
&lt;li&gt;The garage said anyone stranded on a roadside. Whatever they were calling about becomes irrelevant, because a person standing by a motorway is a different kind of caller.&lt;/li&gt;
&lt;li&gt;The medical clinic said anything that reads as symptoms or advice. Not the agent's job at any confidence level.&lt;/li&gt;
&lt;li&gt;The brokerage said any caller mentioning a competing offer. Speed to a human is the entire value of that call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those go into the flow as first-class exits before I write a line of the booking path. Same instinct as writing the throw sites before the function body. It also changes what the client tells you. "Handle our calls" is not a spec. "Never let a stranded driver sit through a booking script" is, and it is testable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trigger set that actually catches escalations
&lt;/h2&gt;

&lt;p&gt;Escalations fire from four sources. An agent that implements only one will miss calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content triggers.&lt;/strong&gt; The caller says something on the list. Emergency words, legal words, cancellation words, a competitor's name. Easy to specify, so most implementations stop here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explicit request.&lt;/strong&gt; The caller asks for a human. Non negotiable, and I wire it at every node in the flow. An agent that keeps steering someone back into the script after they asked for a person is the fastest way to make a caller hate a business. If they ask twice, it should already have happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repair-loop triggers.&lt;/strong&gt; The agent has failed to understand the same thing twice, or the caller has repeated themselves, or the call has run well past the length this kind of call should take. Nothing hit a keyword. The conversation is just going badly, and the agent should detect that from structure alone. This is the trigger everyone forgets and it catches the most calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sentiment triggers.&lt;/strong&gt; The caller is audibly frustrated. Worth having, worth keeping conservative. A false positive means a human takes a call they did not strictly need, which is cheap next to the alternative.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the handoff has to carry
&lt;/h2&gt;

&lt;p&gt;A transfer that drops a confused human into a live call with no context is barely better than a dropped call. The person picking up starts by asking the caller to repeat everything, which is exactly what the business paid to avoid.&lt;/p&gt;

&lt;p&gt;So every handoff carries a payload: who is calling and their callback number, why they called in the agent's own words, what has already been established, and what triggered the escalation. Where the receiving team lives in a CRM, that lands as a note on the contact before the phone rings. The agent stays thin, the systems behind it stay smart.&lt;/p&gt;

&lt;p&gt;The callback number matters most. Get it early, confirm it, and treat it as the one field the agent is genuinely patient about collecting. If the transfer fails, that number is the only thing standing between the business and a lost lead.&lt;/p&gt;

&lt;p&gt;This is also the warm versus cold decision. Cold transfer is fine for high volume routing where the destination already knows what it is for, like someone who just wants the parts desk. If making the caller repeat their story would damage the relationship, brief the receiver first and transfer warm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody picks up, which is the normal case
&lt;/h2&gt;

&lt;p&gt;Here is the part that gets built last and breaks first. Transfer is a request, not a guarantee. It is a network call to a human and it can time out. The line is busy, it is 7pm, someone is on holiday. If the plan was "transfer to a human" and nobody answers, the caller gets silence or a voicemail beep, and the system has failed at the one call it existed to protect.&lt;/p&gt;

&lt;p&gt;What I build instead is a ladder with a terminating case:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Try the primary destination with a short ring window, not a long one. A caller listening to ringing is spending patience they arrived with.&lt;/li&gt;
&lt;li&gt;Try the fallback if there is one. On-call phone, second location, an owner's mobile after hours.&lt;/li&gt;
&lt;li&gt;If nobody picks up, the agent comes back on the line and says so honestly. Not "please hold" again. Closer to what a person would say: everyone is with a customer, here is when someone will call you back.&lt;/li&gt;
&lt;li&gt;Capture callback details, confirm the timeframe, end the call cleanly.&lt;/li&gt;
&lt;li&gt;Fire the escalation into whatever the team actually watches. The agent does the talking, n8n does the alerting, the SMS, the ticket and the follow-up.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step three is the one clients push back on and it is the one that saves them. An agent that admits nobody is available and commits to a callback keeps the lead. An agent that keeps trying to transfer loses it.&lt;/p&gt;

&lt;p&gt;Ring time and lookup time are both spent by the caller, so your &lt;a href="https://nabeelbaghoor.com/blog/voice-agent-latency-budget/" rel="noopener noreferrer"&gt;latency budget&lt;/a&gt; applies to the handoff too. Speak before the silence, not after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I treat this as a safety problem
&lt;/h2&gt;

&lt;p&gt;Most of my thinking about escalation did not come from voice AI. It came from years of building systems where a confident wrong answer was the worst possible output. In clinical eye-tracking work, the headset could produce a number for every session, but the honest system was the one that could say the capture was not good enough and hand the judgement back to a clinician. In public safety XR, an overlay showing stale data with full confidence was more dangerous than one admitting it had lost tracking.&lt;/p&gt;

&lt;p&gt;A voice agent is the same class of system. It will be wrong sometimes, and the question is never how to prevent that entirely. It is what the system does at the edge of its own competence. A well built handoff is the strongest signal that an agent was built by someone who has run one in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Build three exits: transfer live, take a message and escalate, refuse and route.&lt;/li&gt;
&lt;li&gt;Write the exit conditions first, starting from "which calls would make you turn this off".&lt;/li&gt;
&lt;li&gt;Trigger on content, on explicit request at any point, on repair loops, and conservatively on sentiment.&lt;/li&gt;
&lt;li&gt;Carry context into the handoff, and get the callback number early.&lt;/li&gt;
&lt;li&gt;Plan for nobody picking up, and end that path with an honest promise instead of a hold.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The longer version, with more of the client-by-client escalation specs, is &lt;a href="https://nabeelbaghoor.com/blog/voice-agent-human-handoff-transfer/" rel="noopener noreferrer"&gt;on my site&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>automation</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>It Works on Office WiFi: Shipping AR to People Who Will Not Install Anything</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Thu, 13 Aug 2026 21:47:56 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/it-works-on-office-wifi-shipping-ar-to-people-who-will-not-install-anything-k92</link>
      <guid>https://dev.to/nabeelbaghoor/it-works-on-office-wifi-shipping-ar-to-people-who-will-not-install-anything-k92</guid>
      <description>&lt;p&gt;Every AR project I have scoped eventually arrives at the same quiet question, usually about twenty minutes into the kickoff call. Someone wants customers to see a product in their own room, we sketch the happy path, and then a person at the far end of the table says it out loud: are we really going to ask people to download an app for this?&lt;/p&gt;

&lt;p&gt;That question deserves more weight than it usually gets, because the answer changes the entire build. And the trap on the other side of it is not the AR code. It is that your browser-based demo works perfectly on office wifi, on a flagship phone, with the asset already sitting in cache, and none of those three things are true for the person you built it for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Web AR is three different things wearing one name
&lt;/h2&gt;

&lt;p&gt;When a client says web AR, they could mean any of three delivery routes, and they are not interchangeable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route one: hand off to the phone's own viewer.&lt;/strong&gt; You publish one optimized model, put a "view in your space" button on a page, and let the operating system do the AR. On iOS that path expects a USDZ file and opens AR Quick Look. On Android it expects glTF or GLB and opens Scene Viewer. The &lt;code&gt;&amp;lt;model-viewer&amp;gt;&lt;/code&gt; web component wraps both handoffs plus an inline 3D preview, which means a competent front-end dev can ship this in an afternoon once the asset exists.&lt;/p&gt;

&lt;p&gt;You get placement and inspection at true scale. You do not get an interactive experience, because you handed the session to someone else's viewer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route two: run the AR session in the page.&lt;/strong&gt; WebXR, your own render loop, your own interaction. Tapping objects, configuring a product, stepping through a sequence, reacting to what the user does. This is where web AR stops being a viewer and starts being an application.&lt;/p&gt;

&lt;p&gt;The catch is support. Browser AR capability differs across platforms and moves over time, so check what your actual target devices do at build time instead of trusting a feature table you read last year. My rule: anything that only works on some phones is an enhancement, never the thing the campaign depends on. Feature-detect, and have a real fallback that is not an apology page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route three: a native app.&lt;/strong&gt; Full platform capability, assets ship with the binary, sessions can be long. You pay for it in reach, because every single user has to install first.&lt;/p&gt;

&lt;p&gt;The mistake I see is almost never picking the wrong one of these. It is never comparing them, and defaulting to whichever route the first vendor happened to have built before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every byte is paid on every open
&lt;/h2&gt;

&lt;p&gt;This is the part that separates web AR from every other 3D work you have done, and it is worth being blunt about.&lt;/p&gt;

&lt;p&gt;A native app downloads its assets once, from a store, usually over wifi, while the user is willing to wait because they made a decision to install. A web AR page pays the download every time someone opens the link, over whatever connection they have, while standing in a shop, having made no commitment to you whatsoever.&lt;/p&gt;

&lt;p&gt;That single difference sets a hard ceiling on your scene. Not a soft one you can optimize your way past later. So the engineering work is unglamorous and completely non-optional:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compress the geometry.&lt;/strong&gt; Draco or meshopt on the glTF, and measure the decode cost on a mid-range phone, not just the transfer size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compress the textures.&lt;/strong&gt; KTX2 with Basis, so the GPU gets a compressed format instead of your app decoding a 4K PNG on a phone and holding the uncompressed result in memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship the level of detail the moment needs.&lt;/strong&gt; The 2 million triangle hero mesh is not the one that opens in an aisle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put something meaningful on screen while it streams.&lt;/strong&gt; A poster frame, a low-res proxy, a progress state that is not a spinner. People forgive loading. They do not forgive a blank camera view.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then test the way the user will actually experience it. Real phones, cellular data, throttled, cache cleared. Every megabyte you leave in is a slice of your audience who close the tab before the camera opens, and you will never see them in your analytics as anything except a bounce.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3D assets are usually the real project
&lt;/h2&gt;

&lt;p&gt;The other thing that gets scoped at zero: your client already has models, so that part is done. It is not.&lt;/p&gt;

&lt;p&gt;A CAD file exists to manufacture an object. It carries exact surfaces, every internal fastener, every part nobody will ever see, and often the entire sub-assembly tree. A marketing render exists for one camera angle with lighting baked into the image. A photogrammetry scan arrives as one enormous messy lump of geometry with the lighting of the day it was captured burned in.&lt;/p&gt;

&lt;p&gt;All useful starting points. None of them is a deliverable. Getting from any of them to a web asset means retopology, sensible real-time materials, baked lighting where it helps, and export into the formats phones expect. Then multiply that by a product catalogue and asset preparation becomes the largest line in the budget, not the AR code.&lt;/p&gt;

&lt;p&gt;If a vendor has not asked how many products you have and what state the source files are in, they have not scoped the project. They have guessed at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the browser tab genuinely runs out
&lt;/h2&gt;

&lt;p&gt;I am not writing this as a browser purist. Several of the builds I am proudest of had to be native, and it was not close.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tracking precision.&lt;/strong&gt; Browser AR is good at putting an object on a floor and keeping it there while somebody walks around it. It is not the tool when an overlay has to stay locked to a specific piece of real machinery through movement and bad lighting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistence and shared anchors.&lt;/strong&gt; Content that must hold a fixed real-world position across sessions, or that several people need to see in the same place at the same time, needs infrastructure a page load does not carry. MR Camera, a multiplayer mixed reality environment where several users place and interact with 3D models in one shared space, is on headsets for exactly that reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live data and deep platform access.&lt;/strong&gt; Planes XR, which we built for ARCortex, runs real plane simulations in the real world using AR and live OpenSky data. Continuous sensor access, precise geolocation, a live data pipeline. That is a native build for good reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measurement you will act on.&lt;/strong&gt; Nystag, our VR eye-tracking diagnostics work, runs on a Vive Focus 3 because the number at the end has to be defensible. No browser experience competes there and it should not try.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five questions that settle it
&lt;/h2&gt;

&lt;p&gt;Run these in order and the decision usually makes itself:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is the audience the general public, or a known group you can equip?&lt;/strong&gt; Public points at the browser. A known group can install something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is this a look, or a task?&lt;/strong&gt; Inspecting an object is web AR. Performing guided work on real equipment is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does anything need to persist or be shared live?&lt;/strong&gt; If yes, you are in native or headset territory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How heavy is the scene, honestly?&lt;/strong&gt; One product, fine. A detailed environment, no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How long does this need to live?&lt;/strong&gt; A two-week campaign favours a link. A capability you will run for years justifies an app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One more that gets skipped: where does the trigger live? A web AR experience is a link, and links only work where people encounter them. The QR code on the packaging, the button on the product page, the code on a shelf edge. Decide that at the same time as the experience, because it changes what you should build. Someone scanning in a shop aisle has thirty seconds. Someone opening your product page at home has several minutes. Those are two different apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade
&lt;/h2&gt;

&lt;p&gt;Web AR is not a cut-down version of real AR. It is a different trade. You give up tracking precision, persistence, scene weight and platform depth, and in exchange you get every phone that can open a link. For product visualization, packaging, campaigns and public experiences that is a very good deal, and the missing install is frequently the only reason the project works at all.&lt;/p&gt;

&lt;p&gt;For guided work on real equipment, for measurement, for shared spaces, or for anything that has to hold its position across sessions, the browser is the wrong container and no amount of optimization changes that.&lt;/p&gt;

&lt;p&gt;Decide which one you have before you commission the assets. Everything after that gets much easier.&lt;/p&gt;

&lt;p&gt;The longer client-facing version of this, including how we scope the asset pipeline, is on the &lt;a href="https://nullstud.io/blog/webar-without-an-app/" rel="noopener noreferrer"&gt;Null Studio blog&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>ar</category>
      <category>performance</category>
    </item>
    <item>
      <title>Undefined Is Not Null: Notes From Merging Two Voice APIs Into One Table</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Tue, 11 Aug 2026 15:58:11 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/undefined-is-not-null-notes-from-merging-two-voice-apis-into-one-table-1eoj</link>
      <guid>https://dev.to/nabeelbaghoor/undefined-is-not-null-notes-from-merging-two-voice-apis-into-one-table-1eoj</guid>
      <description>&lt;p&gt;I build VoiceDash, a white-label portal that agencies put in front of their own clients. The agency runs AI voice agents on Retell or Vapi. Their client logs into something that looks like the agency's own product and sees calls, transcripts, recordings and summaries, with no mention of me or of the underlying voice platform anywhere.&lt;/p&gt;

&lt;p&gt;Everyone assumes the hard part is the branding. It is not. The branding is a logo, a color and a custom domain. The hard part is a seam nobody sees: two providers, two completely different webhook payloads, and one table that the entire dashboard reads from. That seam is a few hundred lines of unglamorous code, and it has taught me more than anything else in the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two payloads that agree on almost nothing
&lt;/h2&gt;

&lt;p&gt;Retell hands me a call like this: the call object lives under &lt;code&gt;body.call&lt;/code&gt;, the event name under &lt;code&gt;body.event&lt;/code&gt;. The identifier is &lt;code&gt;call_id&lt;/code&gt;. The start time is &lt;code&gt;start_timestamp&lt;/code&gt;, epoch milliseconds. Duration comes precomputed as &lt;code&gt;duration_ms&lt;/code&gt;. The transcript is &lt;code&gt;transcript_object&lt;/code&gt;, an array of &lt;code&gt;{ role, content }&lt;/code&gt; where the bot is called &lt;code&gt;agent&lt;/code&gt;, and if the workspace scrubs PII I get &lt;code&gt;scrubbed_transcript_with_tool_calls&lt;/code&gt; instead. The hangup reason is &lt;code&gt;disconnection_reason&lt;/code&gt;. The audio is at &lt;code&gt;recording_url&lt;/code&gt;, or &lt;code&gt;scrubbed_recording_url&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Vapi hands me the same conceptual thing like this: the call lives under &lt;code&gt;body.message.call&lt;/code&gt;, the event type under &lt;code&gt;body.message.type&lt;/code&gt;. The identifier is &lt;code&gt;id&lt;/code&gt;. The timestamps are &lt;code&gt;startedAt&lt;/code&gt; and &lt;code&gt;endedAt&lt;/code&gt; as ISO strings, and there is no duration field at all, so I subtract the two myself. The transcript is &lt;code&gt;artifact.messages&lt;/code&gt;, where the bot is called &lt;code&gt;assistant&lt;/code&gt; and tool call rows are interleaved with the human turns, so they have to be filtered out. The hangup reason is &lt;code&gt;endedReason&lt;/code&gt;. The audio is at &lt;code&gt;artifact.recordingUrl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Neither design is wrong. They are two teams that made reasonable decisions on different days. But if any of those differences reach my React components, every feature I build afterwards pays a tax forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  One table, and a rule about it
&lt;/h2&gt;

&lt;p&gt;Everything lands in a single &lt;code&gt;Conversation&lt;/code&gt; row: &lt;code&gt;platformCallId&lt;/code&gt;, &lt;code&gt;startedAt&lt;/code&gt;, &lt;code&gt;endedAt&lt;/code&gt;, &lt;code&gt;duration&lt;/code&gt;, &lt;code&gt;endReason&lt;/code&gt;, &lt;code&gt;transcript&lt;/code&gt; as JSON, &lt;code&gt;recordingUrl&lt;/code&gt;, plus the fields that belong to us rather than the provider (&lt;code&gt;tags&lt;/code&gt;, &lt;code&gt;note&lt;/code&gt;, &lt;code&gt;summary&lt;/code&gt;, &lt;code&gt;evaluation&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The rule I hold to is simple. Nothing above the storage layer is allowed to know which provider a call came from. The day a component branches on &lt;code&gt;platform === "RETELL"&lt;/code&gt;, a provider detail has escaped, and it never goes back in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resolve the tenant before you trust the payload
&lt;/h2&gt;

&lt;p&gt;The very first thing the handler does, before it looks at a transcript, is work out whose call this is:&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;agent&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;agent&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;agent&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;agentId&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="k"&gt;else&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;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;agent&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;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&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="na"&gt;platformAgentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RETELL&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="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;agent&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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Agent not found&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&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;Two paths, on purpose. When an agency connects an agent we generate a webhook URL with &lt;code&gt;?agentId=&lt;/code&gt; already on it, which is the fast and unambiguous path. The fallback matches the provider's own agent id, scoped to that platform, for agents that were imported or wired up by hand. If neither resolves, I store nothing.&lt;/p&gt;

&lt;p&gt;In a multi-tenant system the tenant is not a field in the payload. It is a decision I make about the payload, and it has to be the first decision, because every write after it is scoped by the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Log that it arrived before you try to understand it
&lt;/h2&gt;

&lt;p&gt;Immediately after the tenant resolves, before any parsing, a &lt;code&gt;WebhookLog&lt;/code&gt; row gets written with the event name and the platform call id. "Did the event arrive" and "did we understand the event" are two different questions, and at two in the morning you want to answer them separately. Without that row, a provider-side delivery failure and a mapping bug on my side look identical: no data in the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency comes from the natural key
&lt;/h2&gt;

&lt;p&gt;The write is an upsert keyed on &lt;code&gt;platformCallId&lt;/code&gt;, which is unique in the schema. Not on an event id, not on a row id of mine.&lt;/p&gt;

&lt;p&gt;That matters because the same call reaches me more than once by design. Providers retry when they do not get a fast 200. A single call produces several events. My backfill re-reads calls that already arrived by webhook. All three paths converge on one upsert that is safe to run any number of times, with no distributed lock and no dedupe table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Undefined is not null
&lt;/h2&gt;

&lt;p&gt;This is the part I would tattoo on the inside of my eyelids. Here is the update half of that upsert:&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="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration_ms&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration_ms&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;endReason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disconnection_reason&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;recordingUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;recordingUrl&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;Those &lt;code&gt;undefined&lt;/code&gt;s look like defensive clutter. They are the most load-bearing characters in the file. In Prisma, &lt;code&gt;undefined&lt;/code&gt; means do not touch this column, and &lt;code&gt;null&lt;/code&gt; means write null over whatever is there.&lt;/p&gt;

&lt;p&gt;The naive version is &lt;code&gt;transcript: transcript&lt;/code&gt;, and it is wrong for a reason that took me a while to internalize: a later event routinely knows less than an earlier one. Recording URLs appear only after processing finishes. Some event types carry no transcript at all. Write the payload straight through and a perfectly good transcript gets replaced with &lt;code&gt;[]&lt;/code&gt; the moment a lower-information event shows up, and the dashboard silently loses a call it had a minute earlier.&lt;/p&gt;

&lt;p&gt;Late does not mean complete. Merge events by how much they know, not by when they showed up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The update block is an ownership list
&lt;/h2&gt;

&lt;p&gt;The other half of that lesson is what is deliberately missing from the update block: &lt;code&gt;tags&lt;/code&gt;, &lt;code&gt;note&lt;/code&gt;, &lt;code&gt;summary&lt;/code&gt;, &lt;code&gt;evaluation&lt;/code&gt;, &lt;code&gt;autoTags&lt;/code&gt;. Those columns are ours, not the provider's. The summary and evaluation get written minutes later by a separate analysis pass over the transcript. The tags and notes are typed by a human sitting in the portal. If a retried webhook overwrote them, one duplicate delivery would erase a week of somebody's work.&lt;/p&gt;

&lt;p&gt;So the shape ended up being: &lt;code&gt;create&lt;/code&gt; sets everything, &lt;code&gt;update&lt;/code&gt; sets only the columns the provider is the source of truth for. That list is worth writing down explicitly, once, before you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fire and forget, deliberately
&lt;/h2&gt;

&lt;p&gt;After the row is written, two things happen without an &lt;code&gt;await&lt;/code&gt;: the AI analysis call, and the fan-out to any forwarding webhook URLs the agency configured, each with a &lt;code&gt;.catch(() =&amp;gt; {})&lt;/code&gt; on the end. The handler returns success immediately.&lt;/p&gt;

&lt;p&gt;That is a real tradeoff and I will not pretend otherwise. A failed analysis is silent right now: no retry, no dead letter queue, no alarm. What it buys is a handler that answers in around a hundred milliseconds instead of holding the connection open for the length of a model call, and providers punish slow handlers by retrying, which turns one slow call into five duplicate deliveries. Losing a summary is recoverable, because the transcript is already in the row and the analysis can be rerun. A retry storm at peak hours is not. The proper fix is a queue, and it is on the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks are not a delivery guarantee
&lt;/h2&gt;

&lt;p&gt;There is a second path into the same table: an authenticated endpoint that pulls the last hundred calls from the provider's list API for one agent and runs them through the same upsert.&lt;/p&gt;

&lt;p&gt;I did not build it for a hypothetical. Deploys happen mid-call. Keys get rotated. A handler times out and the provider gives up after its retries. Anything that assumes webhooks always land will quietly develop holes in its history, and a client scrolling their call list is exactly who finds those holes first.&lt;/p&gt;

&lt;p&gt;Two honest notes. That backfill is a button today, and the &lt;code&gt;syncMode&lt;/code&gt; field on the agent record is sitting in the schema waiting for the scheduled version. And the Retell mapping now lives in two files, once in the webhook handler and once in the backfill, which means they have to agree forever, by hand. The &lt;code&gt;Platform&lt;/code&gt; enum already has a third value in it, ElevenLabs, with no handler behind it yet, and that third provider is what will finally force the extraction. If I were starting this seam over I would write the mapper first, as a plain function from provider payload to normalized shape, before writing either route. Not for elegance. Because a pure function is trivially testable and an API route handler really is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would hand to anyone building the same seam
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Design the normalized shape before you read a single provider's docs. Let the providers argue with your model, not the other way around.&lt;/li&gt;
&lt;li&gt;Resolve the tenant first, and refuse the request if you cannot. Nothing unattributed gets stored.&lt;/li&gt;
&lt;li&gt;Get idempotency from a natural key that the provider already guarantees is unique.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;undefined&lt;/code&gt; for do not touch, and decide explicitly which columns the provider owns and which ones are yours.&lt;/li&gt;
&lt;li&gt;Record arrival separately from interpretation.&lt;/li&gt;
&lt;li&gt;Build the backfill on day one, and route it through the same write path as the live one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exciting work, and that is close to the point. The boring layer in the middle is the reason the interesting features upstream get to stay simple, and it is the first thing I would build again if I started over tomorrow.&lt;/p&gt;

&lt;p&gt;VoiceDash is at &lt;a href="https://voice-dash.com" rel="noopener noreferrer"&gt;voice-dash.com&lt;/a&gt; if you want to see what all that plumbing holds up.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>api</category>
      <category>architecture</category>
      <category>prisma</category>
    </item>
    <item>
      <title>Four ICPC Habits That Survived Production, and Two I Had to Unlearn</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Sun, 09 Aug 2026 21:51:03 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/four-icpc-habits-that-survived-production-and-two-i-had-to-unlearn-4554</link>
      <guid>https://dev.to/nabeelbaghoor/four-icpc-habits-that-survived-production-and-two-i-had-to-unlearn-4554</guid>
      <description>&lt;p&gt;Every few months the same argument comes back around: does competitive programming make you a better engineer, or does it just make you good at competitive programming? People who did it defend it. People who did not point out, correctly, that nobody has ever shipped a segment tree to production.&lt;/p&gt;

&lt;p&gt;I have been on both sides of that line, so here is my honest accounting.&lt;/p&gt;

&lt;p&gt;In 2021 my team won the ACM-ICPC Asia Topi regional as national champions and qualified for the ICPC World Finals. That is roughly as far as that track goes. Then I went and built things people actually use: XR platforms, a cross-platform C# SDK that had to behave identically on three operating systems, AR systems for fire response, and now AI voice agents that answer real phones for real businesses at 2am.&lt;/p&gt;

&lt;p&gt;Four habits came with me almost untouched. Two I had to actively unlearn before I was useful on a real team.&lt;/p&gt;

&lt;p&gt;The short answer is yes, it helped, but not for the reason people usually give. I have not written a segment tree since university. It helped because contests train a specific relationship with a hard problem, and that relationship turns out to be the rare thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What transferred
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Being stuck is a state you exit, not a condition that happens to you
&lt;/h3&gt;

&lt;p&gt;This is the big one. Everything else on the list is downstream of it.&lt;/p&gt;

&lt;p&gt;In a contest you have five hours and a problem you have never seen. There is no Stack Overflow answer, no library that does it, no senior engineer to tap on the shoulder. You have a whiteboard, two teammates, and the requirement that something correct exists at the end. You learn to sit inside not-knowing without panicking, break the problem into a shape you can attack, and keep generating approaches after the obvious three have failed.&lt;/p&gt;

&lt;p&gt;That is exactly what production work asks for, just stretched over weeks instead of hours. When I had to make a live data stream sit correctly on top of the physical world inside a headset, nobody had written that blog post. When a voice agent starts behaving strangely on real calls overnight, there is no documented answer either, because the failure is specific to your prompt, your telephony provider, your callers and your Tuesday.&lt;/p&gt;

&lt;p&gt;The skill is not knowing things. It is having a reliable process for the moment you do not know, and a contest is five hours of pure practice at that, repeated for years.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Correctness is not a feeling
&lt;/h3&gt;

&lt;p&gt;In a contest your solution is right or it is not. The judge does not care how confident you were. It does not care that it worked on the sample input. There is no partial credit for elegant code that fails one edge case.&lt;/p&gt;

&lt;p&gt;That is brutal and it is extremely healthy calibration. It kills the instinct to declare victory because something ran once without crashing. You start reflexively asking the question that separates working software from software that looks like it works: what input breaks this? Empty case, single element, maximum size, duplicates, the exact boundary between two branches.&lt;/p&gt;

&lt;p&gt;Years later that reflex is why I design the ugly call before the happy path when I build an agent, and why I pushed for honest staleness signals in AR overlays instead of a confident dot that might be lying. The contest version is "your solution fails on n equals one." The production version is "your booking flow fails when the caller gives a date in the past, and then confirms it out loud."&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Constraints tell you the answer
&lt;/h3&gt;

&lt;p&gt;Every contest problem hands you the constraints at the top, and reading them properly is half the solve. If n can be a million, you know your approach has to be roughly linear before you write a line. If n is twenty, brute force is intended and the clever solution is a trap you will spend two hours in.&lt;/p&gt;

&lt;p&gt;This transfers directly, and almost nobody does it. Real projects come with constraints too. They are just not printed at the top of the page, and nobody volunteers them. How many calls a day? How stale can this data be before it is dangerous rather than merely wrong? How long will a human wait before they hang up? What is the one thing this must never get wrong, at the cost of everything else?&lt;/p&gt;

&lt;p&gt;I now dig those out before touching architecture, because they collapse the design space fast. It is why an AR overlay for a firefighter and a voice agent for a clinic end up with the same shape underneath. Both have a hard latency ceiling and a hard correctness floor, and once you read those honestly, most of the design has already decided itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Working under a clock without falling apart
&lt;/h3&gt;

&lt;p&gt;Contests are timed, ranked, and public. You will fail in front of people. You will submit something wrong and watch the scoreboard move without you while your teammates watch. You learn to keep thinking clearly while behind, and to triage under pressure: this problem is not going to fall in the time left, move to the one that will.&lt;/p&gt;

&lt;p&gt;That composure is worth more than any algorithm. When I stepped in as CTO at a sports tech company during a critical phase, the technical problems were solvable. What the situation actually demanded was staying calm with a deadline moving toward me and choosing what not to do. Contests are a cheap, safe simulator for exactly that, and five years of them genuinely changes how a bad Friday feels.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I had to unlearn
&lt;/h2&gt;

&lt;p&gt;Now the part nobody says at the awards ceremony. Competitive programming also installs two habits that are actively wrong outside a contest, and my first year of client work was mostly discovering them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contest code is write-once. Production code is not.
&lt;/h3&gt;

&lt;p&gt;In a contest, code lives for five hours. Variable names are single letters. There are no tests, no comments, no abstraction that is not load-bearing in the next twenty minutes. That is optimal for the environment, and a disaster in a repository someone else maintains next year.&lt;/p&gt;

&lt;p&gt;Real code is read far more often than it is written, usually by someone who has never met you, and often by you having forgotten everything. I had to consciously slow down and write for the reader instead of the compiler. The speed I had spent years training was speed at producing a throwaway artifact, and the job was durable systems.&lt;/p&gt;

&lt;p&gt;This one took me longer to accept than I would like to admit, because the old habit felt like competence.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hardest problem is almost never the algorithm
&lt;/h3&gt;

&lt;p&gt;ICPC selects for one skill: solving a well-specified problem. Someone else already did the hard part, which was deciding what the problem is.&lt;/p&gt;

&lt;p&gt;In real work that specification does not exist. The client describes a symptom, not a requirement. The actual difficulty is figuring out what should be built, what it must never get wrong, and what everyone in the room is assuming without saying it out loud. Almost none of my production time goes to algorithmic difficulty. It goes to ambiguity, integration, edge cases in other people's systems, and human factors.&lt;/p&gt;

&lt;p&gt;That was a genuinely humbling adjustment. Being world class at the last mile does not help if you are running the wrong direction, and I had to build the taste for problem definition from scratch, mostly by getting it wrong on other people's budgets.&lt;/p&gt;

&lt;h2&gt;
  
  
  So is it worth it?
&lt;/h2&gt;

&lt;p&gt;If you are a student deciding whether to spend two years on this: yes, if you go in knowing what it is. It is a gym, not a job. It builds problem-solving stamina, correctness instincts and composure at an intensity normal work will never give you, because normal work has deadlines measured in weeks and lets you look things up.&lt;/p&gt;

&lt;p&gt;But treat it as one input, not the whole education. Ship something real alongside it. I was freelancing in Unity through university while competing, and the combination is what mattered. Contests taught me how to think when I am stuck. Client work taught me what was worth thinking about. Neither alone would have gotten me to public safety AR or to production voice agents.&lt;/p&gt;

&lt;p&gt;The people I watched plateau were the ones who believed the ranking was the skill. It is not. The ranking is a side effect of having practiced not-knowing for thousands of hours, and that asset does not expire when the tools do. I have been paid for C++, then Unity, then mobile SDKs, and now LLM agents. Every one of those transitions was survived with the same muscle a five-hour contest builds: sit with the unfamiliar problem, decompose it, attack it, verify it honestly, and do not flinch at the clock.&lt;/p&gt;

&lt;p&gt;The segment trees, though. Those are gone.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nabeelbaghoor.com/blog/competitive-programming-to-production-engineering/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;, where I write about voice agents, XR and the engineering behind both.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>career</category>
      <category>programming</category>
      <category>algorithms</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>Rewrites Do Not Fail in Month One. They Fail in Month Seven.</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Fri, 07 Aug 2026 21:48:01 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/rewrites-do-not-fail-in-month-one-they-fail-in-month-seven-2eho</link>
      <guid>https://dev.to/nabeelbaghoor/rewrites-do-not-fail-in-month-one-they-fail-in-month-seven-2eho</guid>
      <description>&lt;p&gt;Rewrites almost never blow up in the first month. Month one feels great. The new schema is clean, the endpoints are boring in the good way, the tests are green, and everyone is happy that the horrible old thing is finally on its way out.&lt;/p&gt;

&lt;p&gt;They blow up in month seven. The new system is roughly finished, the migration runs, and then somebody from finance opens the reconciliation report, points at a total that is off by a few thousand, and says "that number is wrong." Nobody can explain why. And because nobody can explain why, nobody is willing to be the person who says go.&lt;/p&gt;

&lt;p&gt;I have shipped greenfield products and I have replaced systems a business was actively running on, and they are not the same job in different clothes. The second one is much harder, and the reason is almost never the technology.&lt;/p&gt;

&lt;h2&gt;
  
  
  A greenfield build has nothing depending on it
&lt;/h2&gt;

&lt;p&gt;That is the whole difference. A new product starts from zero. Nothing is mid-transaction inside it, no customer sees its output yet, and if version one is wrong you fix it next week.&lt;/p&gt;

&lt;p&gt;A replacement starts from a system that is already load bearing. Your team has years of muscle memory in it, your customers see its outputs, your month end close depends on it, and it encodes decisions people made years ago that nobody wrote down and everybody now relies on.&lt;/p&gt;

&lt;p&gt;So the build is not the hard part. The excavation and the landing are, and most rewrite plans budget for neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old system has a spec. It is just written in code and habit
&lt;/h2&gt;

&lt;p&gt;Somewhere in that codebase is a rule like "orders from this customer group skip the credit check", or "this fee is calculated differently after the fifteenth", or "these two product codes are treated as one for reporting". Nobody remembers deciding it. It is not in any document. It exists in a conditional written by a developer who left in 2018, and in the head of an operations person who has been quietly compensating for it ever since.&lt;/p&gt;

&lt;p&gt;Every one of those is a requirement. You just do not have the list.&lt;/p&gt;

&lt;p&gt;This is Chesterton's fence at production scale, and the thing that makes it dangerous is that the fences are invisible until you have already torn them down. There is no annotation in the code that says "this branch is deliberate business policy" versus "this branch is a bug we adapted to". Both look identical in a diff.&lt;/p&gt;

&lt;p&gt;The only method I trust is to go find them on purpose. Read the code paths rather than the wishlist, run both systems against the same real inputs, diff the outputs, then put the differences in front of the people who would notice, because they hold the other half of the spec.&lt;/p&gt;

&lt;p&gt;Anyone who proposes to rebuild your system from a feature list, without excavating what the current one actually does, is quoting a different project from the one you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data is worse than you were told, and nobody lied to you
&lt;/h2&gt;

&lt;p&gt;The person commissioning the rewrite genuinely believes the data is fine. They have just never had to look at all of it at once.&lt;/p&gt;

&lt;p&gt;What you will find: duplicates. Fields used for something other than their name, because in 2019 somebody needed a place to put a purchase order number and the notes column was right there. Free text where you want an enum. Rows that predate a rule change and are therefore correct by the old rules and invalid by the new ones. A long tail of entries that violate every constraint your clean new schema wants to enforce.&lt;/p&gt;

&lt;p&gt;Here is the decision that moves the effort more than anything else: how much history has to move. All of it, the last few years, or a summary plus an archive you can query when someone asks. That is a business decision, not a technical one, and I have watched teams silently assume "all of it" and lose weeks to it.&lt;/p&gt;

&lt;p&gt;Treat migration as its own workstream, not a step near the end. Profile what is there, agree the cleaning and mapping rules in writing, and build repeatable scripts rather than one off manual fixes, because you will run this more than once. Rehearse against production scale data at least twice: the first rehearsal exists to find out how bad the data is, the second exists to time it, because "the migration takes fourteen hours" changes a cutover plan that assumed a quiet Sunday morning.&lt;/p&gt;

&lt;p&gt;A migration is not done when it completes without errors. It is done when someone who knows the business looks at the totals and agrees with them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The integrations nobody calls integrations
&lt;/h2&gt;

&lt;p&gt;Accounting, payments, a shipping provider, a data feed, an internal reporting tool. Those you find in the first meeting.&lt;/p&gt;

&lt;p&gt;The ones that hurt are the ones nobody thinks of as systems. The CSV emailed every Friday and pasted into a board pack. The spreadsheet with a macro that one person maintains. The scheduled job on a machine under a desk. Every one of those is a consumer of your data contract and none of them appear on an architecture diagram. Go looking for the outputs, not just the APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Big bang versus strangler fig
&lt;/h2&gt;

&lt;p&gt;Two ways to land it, and this choice matters more than your stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Big bang.&lt;/strong&gt; Build everything, migrate everything, switch on a date. Simpler to reason about, and you avoid running two systems at once. The problem is that all the risk arrives on one day and the fallback is a rollback nobody wants to execute in front of customers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incremental, one capability at a time.&lt;/strong&gt; The strangler fig. Put the new system alongside the old one and move functions across in slices, starting with something valuable but not existential. Reporting. A customer portal. One module such as purchasing. The old system stays authoritative for everything not yet moved, and the two are kept in step until each slice is proven.&lt;/p&gt;

&lt;p&gt;Incremental costs more in total, because you are paying for integration and synchronization that will be thrown away, and it demands real discipline about which system owns which data at any given moment. That ownership question is where most incremental migrations get messy. In exchange, no single failure takes the business down.&lt;/p&gt;

&lt;p&gt;For anything that genuinely runs the business, I go incremental. The exception is a system that cannot be integrated with at all, and then the honest plan is a big bang with a long parallel run and a written rollback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parallel running is the only test suite that counts
&lt;/h2&gt;

&lt;p&gt;For a period, real work goes through both systems and you compare the outputs. Shadow mode. Dual write, single read, diff everything.&lt;/p&gt;

&lt;p&gt;Your team will not enjoy it, because it is duplicated effort against a system they are trying to kill. Do it anyway. Unlike your unit tests, it exercises the real distribution of production inputs, including the ugly ones you would never have thought to write a case for, so it is the most effective way to find the undocumented rules before they find you.&lt;/p&gt;

&lt;p&gt;Reconcile against numbers the business already believes, not against your own expectations, which came from the same incomplete understanding that produced the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where AI actually helps, and where it does not
&lt;/h2&gt;

&lt;p&gt;The build side compresses hard, and this is not a small effect. Coding agents under senior review are genuinely good at the work a replacement is full of: reading an unfamiliar codebase and explaining what a path does, drafting transformation scripts, generating the test coverage a rewrite needs, and producing the volume of ordinary screens and endpoints a business system requires. Some of the old manual processes also stop being screens at all, because a process that only existed so a human could move information between two systems can often just become automation.&lt;/p&gt;

&lt;p&gt;What does not compress is the deciding. Which rules to keep and which were never intentional. How much history to carry. Which slice goes first. When the business is confident enough to cut over.&lt;/p&gt;

&lt;p&gt;That matters more than it sounds, because the ratio shifts. If the build used to be seventy percent of the calendar and is now thirty, the excavation and the landing are now most of your project, and they are exactly the parts nobody staffs properly. Building faster only helps if you spend the time you saved on the parts that did not get faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sequence I run
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Map the current system as it behaves, not as it is described. Code paths, real data, the reports people actually use, and the workarounds. The workarounds are requirements in disguise.&lt;/li&gt;
&lt;li&gt;Separate the deliberate rules from the accidents, and get that list agreed in writing.&lt;/li&gt;
&lt;li&gt;Name the first slice. Valuable, visible, survivable if it slips.&lt;/li&gt;
&lt;li&gt;Treat data as its own workstream, with profiling, rules, rehearsals and reconciliation.&lt;/li&gt;
&lt;li&gt;Run in parallel and compare until the outputs stop being a discussion.&lt;/li&gt;
&lt;li&gt;Cut over with a written rollback, then keep a team on it, because the weeks after cutover are when the last hidden rules surface.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of that is clever engineering. It is mostly just refusing to skip the boring parts.&lt;/p&gt;

&lt;p&gt;A replacement is not a better version of a system you already have. It is a transfer of something that is currently running, while it keeps running. Get the excavation and the landing right and the build really is the straightforward bit. Get them wrong and it does not matter how good your new system is, because nobody will be willing to switch to it.&lt;/p&gt;

&lt;p&gt;I wrote a longer version of this for the studio site, aimed at the people commissioning these projects rather than the people building them: &lt;a href="https://nullstud.io/blog/legacy-system-modernization/" rel="noopener noreferrer"&gt;replacing a legacy system&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>legacycode</category>
      <category>refactoring</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Assumed Day Zero Was a Monday: What One Integer Did to My Habit App</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Thu, 06 Aug 2026 21:47:49 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/i-assumed-day-zero-was-a-monday-what-one-integer-did-to-my-habit-app-3922</link>
      <guid>https://dev.to/nabeelbaghoor/i-assumed-day-zero-was-a-monday-what-one-integer-did-to-my-habit-app-3922</guid>
      <description>&lt;p&gt;Lifemaxxing AI is a habit app I build with a co-founder. It runs a 21 day program: you answer some onboarding questions, it assigns you a set of daily tasks, and it scores you on six RPG style attributes as you complete them. I have written before about how the scoring model broke. This one is smaller and, I think, more instructive, because the scoring model broke loudly and this broke quietly.&lt;/p&gt;

&lt;p&gt;The app has one integer called &lt;code&gt;_currentDayIndex&lt;/code&gt;. It runs 0 to 20. Almost every screen reads it. Without anyone deciding this, it became the app's clock. Then it quietly became the app's calendar too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design that felt obvious
&lt;/h2&gt;

&lt;p&gt;You are building a 21 day program. Days are numbered. So you number them.&lt;/p&gt;

&lt;p&gt;Completion history is a map from task ID to a list of booleans, one slot per day:&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="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;completionHistory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// completionHistory['pushups'][4] == true  -&amp;gt;  pushups done on day 5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Streaks walk that list backwards from today:&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="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;dayToCheck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;currentDay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dayToCheck&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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="n"&gt;isScheduledDay&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="n"&gt;dayToCheck&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;taskCompletions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;taskCompletions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dayToCheck&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;streak&lt;/span&gt;&lt;span class="o"&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;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;break&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="n"&gt;dayToCheck&lt;/span&gt;&lt;span class="o"&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;This is clean. It is also a positional array, and positional arrays have a property I did not respect early enough: a wrong index is never an error. It is just a different answer that looks equally valid. No type complains, nothing throws, nothing logs. The checkmark lands on the wrong day and the UI renders it with total confidence.&lt;/p&gt;

&lt;p&gt;So the correctness of the whole feature rests on one thing. &lt;code&gt;currentDay&lt;/code&gt; had better be right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem one: a derived value you can also write
&lt;/h2&gt;

&lt;p&gt;Here is how the day gets computed on launch:&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;daysSinceStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;difference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_programStartDate&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inDays&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;_currentDayIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;daysSinceStart&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;totalDays&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AppStorage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;saveCurrentDay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_currentDayIndex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a derivation. The day is a function of the start date and the current time. Fine so far.&lt;/p&gt;

&lt;p&gt;But the same field has setters:&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="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;advanceToNextDay&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&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="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;goToPreviousDay&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&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="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;jumpToDay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;dayIndex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&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;All three write to storage. They exist for testing and for letting a user look back at earlier days, which are both legitimate needs. And the moment they exist, &lt;code&gt;current_day_index&lt;/code&gt; in storage means two different things depending on how it got there. Sometimes it is a cache of a derivation. Sometimes it is an override that beat the derivation. Nothing in the stored value tells you which one you are holding.&lt;/p&gt;

&lt;p&gt;This is the part I would go back and tell myself. If a value is derived, it should not have a setter. It should be a getter that recomputes, and anything that wants to look at a different day should be a separate &lt;code&gt;viewingDayIndex&lt;/code&gt; that the UI reads and the domain logic never touches. Once you can write to a derived field you no longer have a derivation. You have a cache with no invalidation rule, which is a bug with a delay on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem two: two anchors for one question
&lt;/h2&gt;

&lt;p&gt;There are two ways this codebase answers "what day is it".&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="c1"&gt;// one&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;daysSinceStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;difference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_programStartDate&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inDays&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// two&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;onboardingDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AppStorage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOnboardingCompleteDate&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;daysSinceOnboarding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;difference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onboardingDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inDays&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both live in the same provider. Both are called. They agree only if the program start date and the onboarding completion date are the same instant, which they are not, because &lt;code&gt;startProgram()&lt;/code&gt; guards on &lt;code&gt;if (_programStartDate == null)&lt;/code&gt; and can be reached from more than one path.&lt;/p&gt;

&lt;p&gt;Two functions answering the same question from two different anchors is not a bug you find by reading either function. Each is correct on its own terms. You only see it when you put them side by side, which nobody does, because they are in different sections of the file and they have different names.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem three: a day is not 24 hours
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Duration.inDays&lt;/code&gt; truncates. It counts elapsed 24 hour periods, not calendar days.&lt;/p&gt;

&lt;p&gt;A user finishes onboarding at 11:00pm. Midnight passes. It is a new date on their phone, the app still says Day 1, and it keeps saying Day 1 until 11:00pm the following night. Their first day is 25 hours long and their tasks reset at a time that has nothing to do with their day.&lt;/p&gt;

&lt;p&gt;What makes this worth writing down rather than just fixing: the same file already knows better.&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="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;isNewDay&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;lastActiveDay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;_lastActiveDate&lt;/span&gt;&lt;span class="o"&gt;!.&lt;/span&gt;&lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_lastActiveDate&lt;/span&gt;&lt;span class="o"&gt;!.&lt;/span&gt;&lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_lastActiveDate&lt;/span&gt;&lt;span class="o"&gt;!.&lt;/span&gt;&lt;span class="na"&gt;day&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;todayDay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;todayDay&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lastActiveDay&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 one normalizes to midnight and compares calendar dates. It is right. It sits about 90 lines below the one that is not. Two definitions of "a day" in a single file, written by the same person, each looking reasonable in isolation.&lt;/p&gt;

&lt;p&gt;Neither is timezone aware, either. &lt;code&gt;DateTime.now()&lt;/code&gt; is local, and the stored ISO strings carry whatever offset the device had at write time. Fly east and you can lose a day. Fly west and you can gain one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem four: the integer became a calendar
&lt;/h2&gt;

&lt;p&gt;This is the one that made me stop and plan a rewrite.&lt;/p&gt;

&lt;p&gt;Tasks can be scheduled on specific weekdays, so the streak calculator needs to know which weekday a given program day falls on. Here is how it finds out:&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="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;_getDayOfWeek&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;dayIndex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Day index starts from 0, and we assume day 0 is Monday&lt;/span&gt;
  &lt;span class="c1"&gt;// So: 0=Monday(1), 1=Tuesday(2), ..., 6=Sunday(7)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dayIndex&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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;Read the comment again. The weekday is derived from the program day index by modulo. There is no date anywhere in that calculation.&lt;/p&gt;

&lt;p&gt;If a user starts on a Thursday and picks a Monday, Wednesday, Friday schedule, the app schedules those tasks on their Thursday, Saturday and Monday. Nothing warns anyone. The streak is then computed against the same fake calendar, so the result is internally consistent and externally wrong, which is the worst combination on offer. Internally consistent wrong answers do not get reported as bugs. They get reported as "the app feels off".&lt;/p&gt;

&lt;p&gt;The integer did not stay a clock. It became a calendar, because once a value is the only thing in scope that knows anything about time, every piece of code that needs time will reach for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The storage layer will not save you
&lt;/h2&gt;

&lt;p&gt;One more, because it compounds. All of this lives in SharedPreferences, a schemaless key value store. Every read is a parse, and every parse is a place where a type can quietly change under you.&lt;/p&gt;

&lt;p&gt;There is real repair code in the storage service for a key that was written as a &lt;code&gt;String&lt;/code&gt; and read back where a &lt;code&gt;List&amp;lt;String&amp;gt;&lt;/code&gt; was expected:&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawValue&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kt"&gt;String&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;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonDecode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawValue&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="n"&gt;decoded&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kt"&gt;List&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="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// otherwise wrap the single string in a list&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And completion booleans are serialized as strings, then parsed back by string comparison:&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="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;completionStrings&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything that is not literally "true" becomes &lt;code&gt;false&lt;/code&gt;. A corrupt entry does not throw. It becomes a missed day, which breaks a streak, which lowers an attribute score. The failure travels three layers before a human sees it, and by then it presents as a scoring bug, so that is where you go looking.&lt;/p&gt;

&lt;p&gt;Local storage is a database. It has no schema, no constraints and no migrations, and you own all three. Writing a version number next to the data on day one costs nothing. Adding one after you have shipped to users costs a migration you have to guess your way through.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would build instead
&lt;/h2&gt;

&lt;p&gt;The fix is not clever. It is mostly refusing to store anything I can compute.&lt;/p&gt;

&lt;p&gt;Store events, not slots. A completion becomes a record of &lt;code&gt;{taskId, completedOnLocalDate, utcTimestamp, tzOffset}&lt;/code&gt;, appended, never indexed positionally. Nothing can land in the wrong slot if there are no slots.&lt;/p&gt;

&lt;p&gt;One anchor, written once. A single &lt;code&gt;programStartLocalDate&lt;/code&gt; normalized to midnight local. Every other time question is a pure function of it.&lt;/p&gt;

&lt;p&gt;Day boundaries on calendar dates, never on &lt;code&gt;Duration.inDays&lt;/code&gt;. Normalize both ends to midnight, then subtract.&lt;/p&gt;

&lt;p&gt;Weekdays come from real dates. &lt;code&gt;startDate.add(Duration(days: i)).weekday&lt;/code&gt;, not &lt;code&gt;(i % 7) + 1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;currentDay&lt;/code&gt; is a getter with no setter. Browsing history is a separate &lt;code&gt;viewingDay&lt;/code&gt; that only the UI reads.&lt;/p&gt;

&lt;p&gt;The general version, and the reason I am bothering to write it down: the bug was never inside any one function. Every function quoted here is defensible on its own. The bug was that a derived value got a permanent home in storage, and then everything else in the app started treating that home as the truth. Derived state stops being derived the moment you can write to it, and the code reading it has no way to tell the difference.&lt;/p&gt;

&lt;p&gt;If you are building on any kind of program timeline, a streak, a cohort, a trial period, a drip sequence, that is the question worth asking on day one. What is the single anchor, and is every other time value in this app a pure function of it?&lt;/p&gt;

&lt;p&gt;Lifemaxxing AI lives at &lt;a href="https://lifemaxxingai.com/" rel="noopener noreferrer"&gt;lifemaxxingai.com&lt;/a&gt;, if you want to see what all of this was in service of.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>architecture</category>
      <category>mobile</category>
    </item>
    <item>
      <title>A Faster Model Will Not Fix Your Slow Voice Agent</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Wed, 05 Aug 2026 21:49:36 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/a-faster-model-will-not-fix-your-slow-voice-agent-chf</link>
      <guid>https://dev.to/nabeelbaghoor/a-faster-model-will-not-fix-your-slow-voice-agent-chf</guid>
      <description>&lt;p&gt;The first thing I check when a client tells me their AI voice agent "feels slow" is not the model. It is almost never the model. I have swapped a faster model into a sluggish agent and had callers notice nothing at all, because the seconds they were feeling were never being spent where everyone assumed.&lt;/p&gt;

&lt;p&gt;If you have ever profiled a slow page and found the real cost was a render blocking script rather than the function you spent all week optimizing, you already understand this post. Voice just makes the mistake more expensive, because a phone call has no spinner. No loading state, no skeleton UI, no way to signal "still working" other than talking. Silence is the only progress indicator you get, and to a human on a phone, silence means the line dropped, the other person is confused, or nobody is listening. All three make callers talk over the agent, repeat themselves, or hang up.&lt;/p&gt;

&lt;p&gt;I build production voice agents on Retell wired into n8n, GoHighLevel and Twilio. Before that I spent years in Unity and AR, where a late frame was a safety problem rather than a conversion problem. That is where I learned to treat latency as a property of the whole chain rather than a number attached to one component.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metric is turn latency, not time to first token
&lt;/h2&gt;

&lt;p&gt;The number everybody quotes is time to first token, or whatever response time the vendor publishes. The number callers experience is turn latency: the gap between the moment they stop speaking and the moment they hear the agent start.&lt;/p&gt;

&lt;p&gt;That gap is a sum, and the model is one term in it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Endpointing.&lt;/strong&gt; The system waits until it is confident the caller is done. A deliberate wait, and frequently the largest single item in the budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transcription.&lt;/strong&gt; Speech becomes text. Usually streaming, so most of it overlaps with the caller still speaking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reasoning.&lt;/strong&gt; The model reads state and decides what to say, and whether to call a tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool calls.&lt;/strong&gt; Anything the agent must ask an external system before it can answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speech generation.&lt;/strong&gt; The reply becomes audio, and audio takes real time to play no matter how fast it was generated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network and telephony.&lt;/strong&gt; Delay you do not control.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two things fall out of writing the chain down. Shaving 200ms off step 3 while step 1 sits at two seconds is the classic optimizing-the-wrong-thing move. And steps 4 and 5 are where badly behaved agents actually lose people.&lt;/p&gt;

&lt;h2&gt;
  
  
  Endpointing is the config you set once and never looked at again
&lt;/h2&gt;

&lt;p&gt;Endpointing is the agent deciding you finished your sentence. Too short and it interrupts people mid thought, the rudest failure mode a phone system has. Too long and every turn carries a dead pause, so the agent feels slow even when everything downstream is fast.&lt;/p&gt;

&lt;p&gt;The mistake is treating it as one global constant. Different moments deserve different patience:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short answers get short waits.&lt;/strong&gt; "Yes." A confirmation. A one word choice. Lingering here is what makes an agent feel sluggish across an entire call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open questions get long waits.&lt;/strong&gt; Ask someone why they are calling and they think out loud, pause, resume. Cut them off and you lose the actual reason for the call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything recited digit by digit gets the most patience.&lt;/strong&gt; People say phone numbers in chunks with real gaps. An agent that jumps in after the first chunk gets the number wrong, and a wrong callback number is a lost lead no matter how snappy the call felt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same shape as the rest of agent design: loose where the caller is exploring, tight where the caller is confirming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule one: nothing on the critical path the next sentence does not need
&lt;/h2&gt;

&lt;p&gt;This one rule fixes most "slow agent" complaints I get handed.&lt;/p&gt;

&lt;p&gt;A voice agent wired into a real business touches a CRM, a calendar, a messaging platform, an automation layer. The instinct is to do all of it inline, mid conversation, while the caller listens to nothing. That is how you get four seconds of silence after someone says "yes, book me in".&lt;/p&gt;

&lt;p&gt;Split the work by one question: does the caller's next sentence depend on it?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blocking work&lt;/strong&gt; is anything the agent must know before it can speak truthfully. Real availability before it offers a slot. Whether an account exists before it claims one does. Keep this list short and defend its shortness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-blocking work&lt;/strong&gt; is everything else. Creating the contact, tagging the lead, firing the confirmation SMS, writing the summary, kicking off follow up. None of it belongs in the caller's waiting time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice the agent fires a webhook at the automation layer and keeps talking. This is the job queue pattern every backend engineer already knows, applied where latency is measured in human patience. Nobody is standing at the CRM watching for the row to appear, so the row can appear five seconds late.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one wait worth taking, and how to cover it
&lt;/h2&gt;

&lt;p&gt;Calendar availability is the honest exception. If the agent offers a slot it has not verified, you traded a two second pause for a double booking, which is far more expensive to explain to a client.&lt;/p&gt;

&lt;p&gt;So take the wait, but never take it in silence. Speak first, then look up. "Let me check what we have this week" costs about a second and a half of speech, roughly what the lookup needs, and the caller experiences zero dead air because a voice was talking the whole time.&lt;/p&gt;

&lt;p&gt;That is not a trick. It is what a receptionist does while their booking screen loads, and it is the same instinct as an optimistic UI update. The rule generalizes: &lt;strong&gt;if you cannot remove a wait, cover it with speech that was going to happen anyway.&lt;/strong&gt; What you must not do is fill the gap with filler promising progress the system is not making. Callers notice an agent that says "one moment" three times in a row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule two: the agent's own sentences are part of the budget
&lt;/h2&gt;

&lt;p&gt;Generated speech plays at human speed. A four sentence answer takes four sentences worth of seconds no matter how fast the model produced it. If your agent opens with a paragraph, you spent more of the caller's patience on your greeting than on any technical delay in the stack.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cap reply length in the prompt and mean it.&lt;/strong&gt; One or two sentences, then a question. Long thorough answers read beautifully in testing and feel interminable on a phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Front load the useful part.&lt;/strong&gt; Answer, then explain if asked. A caller who has their answer will interrupt the rest, and that is a good outcome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never read back more than necessary.&lt;/strong&gt; Confirm what would be expensive to get wrong. Skip the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the greeting to one line.&lt;/strong&gt; It is your time to first byte, and every caller pays it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rule three: keep the per-turn work small
&lt;/h2&gt;

&lt;p&gt;Prompt size and tool count both cost time, and both grow quietly. Every rule added after one bad call, every edge case pasted into the system prompt, every tool bolted on for a feature used twice a month adds work to every turn of every call forever. It is dependency creep, and it bills you per request.&lt;/p&gt;

&lt;p&gt;The structural fix is the one that also makes agents more reliable: break the single long prompt into a flow where each node carries only the context and tools it needs. The node collecting a phone number does not need the refund policy in its prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I actually test this
&lt;/h2&gt;

&lt;p&gt;Average latency is a comforting and largely useless number. Callers do not remember the average turn. They remember the one four second gap in the middle of giving their address. Voice is a p99 problem wearing a p50 dashboard.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Listen to whole recordings, not dashboards.&lt;/strong&gt; Latency problems are obvious in twenty seconds of audio and invisible in a summary metric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the paths that touch other systems.&lt;/strong&gt; The booking turn, the lookup turn, the handoff turn. That is where the seconds hide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test messy callers.&lt;/strong&gt; Someone who volunteers their number before being asked, someone who pauses mid sentence, someone with a TV on in the background. Those turns reveal what your endpointing settings really are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test when the business is busy&lt;/strong&gt;, because third party systems slow down under load and your agent inherits all of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where I learned to think this way
&lt;/h2&gt;

&lt;p&gt;In my AR years, latency was not a conversion metric. If an overlay lagged the operator's head, the illusion broke and the person stopped trusting the headset entirely. The fix there is the fix here: decouple what the user feels from what the system does. A firefighter looking at a stale marker and a caller listening to three seconds of silence are having the same experience, which is a system failing to prove it is still there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;If an agent feels slow, work down this list in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tune endpointing per moment instead of globally. Tight on short answers, patient on open questions and digits.&lt;/li&gt;
&lt;li&gt;Move everything the next sentence does not need off the critical path.&lt;/li&gt;
&lt;li&gt;Cover the one unavoidable wait, availability, with speech that was going to happen anyway.&lt;/li&gt;
&lt;li&gt;Cut the agent's own sentences down, starting with the greeting.&lt;/li&gt;
&lt;li&gt;Shrink prompts and tool lists per node.&lt;/li&gt;
&lt;li&gt;Test the worst turn on messy calls, not the average turn on clean ones.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of it requires a faster model. Speed on a phone call is a design decision about where the work happens, made over and over, and it is usually the difference between an agent a business trusts with its phone and one that quietly costs it leads.&lt;/p&gt;

&lt;p&gt;I write more about voice agent builds at &lt;a href="https://nabeelbaghoor.com/blog" rel="noopener noreferrer"&gt;nabeelbaghoor.com/blog&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>performance</category>
      <category>automation</category>
    </item>
    <item>
      <title>The Headset Is the Sensor: Building VR a Clinician Will Actually Act On</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Wed, 05 Aug 2026 01:12:55 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/the-headset-is-the-sensor-building-vr-a-clinician-will-actually-act-on-40li</link>
      <guid>https://dev.to/nabeelbaghoor/the-headset-is-the-sensor-building-vr-a-clinician-will-actually-act-on-40li</guid>
      <description>&lt;p&gt;I have shipped XR that gets judged on whether it feels good, and XR that gets judged on whether a doctor can act on the number it produced. They look like the same stack from the outside. Unity, a headset, a scene, a build pipeline. They are not the same job at all, and the gap between them is where most clinical VR projects quietly fall apart.&lt;/p&gt;

&lt;p&gt;The shift is small to describe and enormous to build for. A consumer VR app degrades gracefully. If tracking drifts half a degree, the game is slightly worse and nobody files a bug. A diagnostic that reports where a patient looked, how fast they reacted, or how far they could reach is only as good as the measurement underneath it, and a clinician is going to make a decision on that measurement. The moment your output is a number someone relies on, you are not writing an app with a medical theme. You are writing an instrument.&lt;/p&gt;

&lt;p&gt;Here is what actually changes when you cross that line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headset stops being a display and becomes a sensor
&lt;/h2&gt;

&lt;p&gt;The most useful reframe I know for clinical VR: you are not choosing hardware for what it renders, you are choosing it for what it captures.&lt;/p&gt;

&lt;p&gt;We built Nystag, VR eye-tracking diagnostics on the Vive Focus 3, used for precise medical assessments and clinical evaluations. The entire product is capturing exactly what the eyes do, accurately enough that a clinician can rely on it. The Focus 3 was picked because its tracking fidelity supports that use case, not because it renders the prettiest world. If your product hinges on a signal, the device selection is a sensing decision that happens to come bundled with a GPU.&lt;/p&gt;

&lt;p&gt;That reorders the whole effort budget. On a consumer build, most of the work sits in the scene: art, interaction, feel, performance. On a measurement build, the scene is often the smallest part, and the real engineering moves into the pipeline that turns a raw sensor stream into a result you would defend in front of someone with a medical license. Same engine, completely different center of gravity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repeatability is a feature you write, not a property you get
&lt;/h2&gt;

&lt;p&gt;This is the part I would tattoo on a project kickoff doc.&lt;/p&gt;

&lt;p&gt;A clinical assessment is usually a comparison. Patient against a norm, or patient against themselves last month. That comparison only means something if the test ran the same way both times. If the result moved, it has to be because the patient moved, not because the calibration was different, the room was brighter, the headset sat lower, or the session started from a slightly different state.&lt;/p&gt;

&lt;p&gt;So standardization stops being a nice property and becomes something you deliberately engineer: lock the stimulus, lock the environment, lock the calibration procedure, lock the order of operations, and remove every degree of freedom that is not the patient. If an operator can accidentally run the test two different ways, they eventually will, and you will not be able to tell which sessions are comparable after the fact.&lt;/p&gt;

&lt;p&gt;The verification that follows is the same idea applied to yourself. A consumer app is done when it feels good. A measurement tool is done when the same input produces the same reading, session after session, and you have actually checked that rather than assumed it. That validation work is a real, budgeted part of the build, and it is exactly the part a flashy demo skips.&lt;/p&gt;

&lt;h2&gt;
  
  
  Be honest about what your number establishes
&lt;/h2&gt;

&lt;p&gt;There is a specific kind of overclaiming that shows up in this space, and it is worth naming because engineers do it accidentally.&lt;/p&gt;

&lt;p&gt;A tool can measure something precisely and still not establish the clinical thing people want it to establish. Those are separate claims. Precision is yours to engineer. Clinical validity is a much bigger conversation, and any use tied to formal medical claims sits inside a regulatory process that a good build alone does not clear. The right posture is to scope the software carefully with that in mind, be exact about what the output does and does not mean, and let the people whose job that is handle the rest. Saying "this measures X consistently" is defensible. Letting a demo imply "this diagnoses Y" is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patient data is a day-one design constraint
&lt;/h2&gt;

&lt;p&gt;The moment a session captures anything about a real patient, you are handling sensitive health information. That has to be designed in from the first architecture decision, not retrofitted before launch.&lt;/p&gt;

&lt;p&gt;In practice that means deciding early what actually leaves the headset, what gets stored, where it lands, how it is transmitted, how long it lives, and what happens to any recordings. It is much cheaper to design a system that never accumulates data it does not need than to go back and unpick one that does. If you are evaluating a vendor for this kind of build, ask exactly how they handle patient data and expect a specific answer rather than a shrug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The patient is not a gamer who chose to be there
&lt;/h2&gt;

&lt;p&gt;This one is easy to underweight if your instincts come from consumer VR.&lt;/p&gt;

&lt;p&gt;Your user did not opt into a headset for fun. They may be unwell, older, anxious, or completely new to VR. Comfort, session length, simplicity, and an interface a non-technical person can follow all matter more here than in a consumer app, and not only for kindness reasons. A test somebody cannot sit through cleanly produces bad data no matter how good your sensors are. Comfort is upstream of data quality, which makes it an engineering concern, not a polish item.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to scope one without it turning into a science project
&lt;/h2&gt;

&lt;p&gt;The most expensive mistake I see is commissioning a broad platform before proving the one measurement at its heart is trustworthy. The sequencing that keeps these grounded:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prove the core measurement first.&lt;/strong&gt; If the product hinges on capturing one signal accurately, whether that is eye movement, reach, balance, or reaction time, build and validate that before anything else. Everything downstream is comparatively predictable once the measurement is solid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate before you expand.&lt;/strong&gt; Confirm the tool produces consistent results on the single most important test before adding a second one. Repeatability is the foundation, not a later feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design for the data from day one.&lt;/strong&gt; If it touches patient information, the handling and privacy posture are part of the minimum build, not a phase two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the device for the sensing, not the scene.&lt;/strong&gt; Tracking fidelity, comfort, and support beat graphics every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice that none of those four are about the 3D. The 3D is the part everyone can already do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Clinical VR earns its keep when a headset can measure or deliver something with a precision and consistency the real world cannot easily match. But the technology matured past the immersion problem years ago. What is left is tracking you can trust, tests that repeat identically, results you can defend, and patient data handled the way health data has to be.&lt;/p&gt;

&lt;p&gt;If you are coming from games or consumer apps, the instinct to make it look good is the one to hold loosely here. The scene is the packaging. The number is the product.&lt;/p&gt;

&lt;p&gt;We wrote up how we scope these builds in more detail over at &lt;a href="https://nullstud.io/blog/clinical-vr-diagnostics/" rel="noopener noreferrer"&gt;Null Studio&lt;/a&gt;, including where the cost actually sits.&lt;/p&gt;

</description>
      <category>vr</category>
      <category>unity3d</category>
      <category>healthcare</category>
      <category>testing</category>
    </item>
    <item>
      <title>Cheap Filters First, LLM Last: Running an AI Matcher Inside a Cron Job</title>
      <dc:creator>Nabeel Hassan</dc:creator>
      <pubDate>Mon, 03 Aug 2026 20:28:20 +0000</pubDate>
      <link>https://dev.to/nabeelbaghoor/cheap-filters-first-llm-last-running-an-ai-matcher-inside-a-cron-job-702</link>
      <guid>https://dev.to/nabeelbaghoor/cheap-filters-first-llm-last-running-an-ai-matcher-inside-a-cron-job-702</guid>
      <description>&lt;p&gt;I run a small SaaS called &lt;a href="https://upwork-scout.com" rel="noopener noreferrer"&gt;Upwork Scout&lt;/a&gt;. It watches Upwork around the clock and emails you only the jobs that actually fit you. The pitch takes one sentence. The engineering is a long argument with your own billing page.&lt;/p&gt;

&lt;p&gt;I have written before about the scraping half of that argument. The short version: naive job alert tools scrape once per user, so cost grows linearly with signups and you go broke on your own success. I inverted it into one shared scrape per category per cycle, and scraping cost now tracks the number of active categories instead of the number of users.&lt;/p&gt;

&lt;p&gt;Then I added the part people actually pay for, AI matching, and immediately created a second bill with worse scaling properties than the first one. Scraping cost tracks categories. Model calls track jobs times users. That multiplication is where most "we added AI" features quietly die.&lt;/p&gt;

&lt;p&gt;Here is the shape I landed on, and the four rules that got me there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: the model never sees a job a boring filter would have rejected
&lt;/h2&gt;

&lt;p&gt;Every user has hard filters. Around twenty of them: job type, budget floor and ceiling, hourly range, experience level, project length, workload, max proposals, whether the client is payment verified, minimum client lifetime spend, minimum client rating, hire counts, country allow and deny lists, keyword includes and excludes.&lt;/p&gt;

&lt;p&gt;None of that needs a language model. It is a pure function over two structs, it runs in memory against the shared job stream, and it costs nothing:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;jobMatchesFilters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JobDoc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UserFilters&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;catHit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="c1"&gt;// ... twenty more boring checks&lt;/span&gt;
  &lt;span class="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AI stage only ever runs on jobs that already survived this. In a normal cycle that cuts the candidate set down by an order of magnitude before a single token is spent. The most effective prompt optimization I have ever written was an &lt;code&gt;if&lt;/code&gt; statement.&lt;/p&gt;

&lt;p&gt;There is a subtlety in that filter that took me a while to get right. Upwork does not always expose the data a filter needs. Sometimes the client's total spend is missing, sometimes the rating is absent, sometimes an older stored job predates a field I added later. My first version rejected on missing data, which meant users silently stopped getting alerts and I could not reproduce it. Now the policy is written at the top of the file and applied consistently: &lt;strong&gt;filters only reject on positive evidence&lt;/strong&gt;. Unknown data passes the filter, and the email and dashboard flag the unknown instead of hiding the job. Let the human decide what the scraper could not tell them.&lt;/p&gt;

&lt;p&gt;Small related detail from real data: Upwork mixes full country names and ISO3 codes in the same field. I have seen "United States", "USA", "CAN", "NLD" and "United Kingdom" all in one dump. If you filter countries by string equality, your United States filter drops every job tagged USA and you will never notice, because the failure mode is silence. I keep an alias table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: cache the verdict, not the prompt
&lt;/h2&gt;

&lt;p&gt;Prompt caching is a fine optimization. It is not the one that mattered here.&lt;/p&gt;

&lt;p&gt;The scan runs every fifteen minutes. Without caching, the same user would be re-scored against the same job ninety six times a day, and each of those calls would return the same answer, because neither the job nor the profile changed. So the verdict is the cache key, and the cache key is the pair:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;matches/${uid}_${jobId}  -&amp;gt;  { score, reason, scoredAt }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A job is never scored twice for the same user. Ever. That single rule detaches model spend from scan frequency entirely. I could scan every five minutes and the model bill would not move, because scoring volume is bounded by new jobs times interested users, not by how often the cron fires.&lt;/p&gt;

&lt;p&gt;It also gave me something I did not plan for: a permanent record of why each user was shown each job. When someone emails me asking why they got a particular alert, I can answer with the exact one line reason the model wrote at the time, instead of guessing or re-running a prompt against a model that may have drifted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: cap it per user, and degrade instead of failing
&lt;/h2&gt;

&lt;p&gt;Free plan users get 150 AI scores per day. The counter lives on the user document with the date attached, so it resets naturally without a cleanup job:&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;aiToday&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aiScoresToday&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aiScoresToday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is what happens when someone hits the cap. The tempting move is to skip the user for the rest of the day. That is the wrong call, because the product promise is alerts, not scored alerts. When the budget is gone, the pipeline falls back to filters only for the remaining jobs. The user gets slightly less precise alerts for a few hours instead of silence.&lt;/p&gt;

&lt;p&gt;That is the general principle I keep relearning with AI features: the model is an enhancement layer over something that already works, not a load bearing wall. If your feature has no defined behaviour for "the model did not answer", you have not finished building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: the scorer returns null, it never throws
&lt;/h2&gt;

&lt;p&gt;The scoring function has one hard contract. No API key, network error, refusal, malformed JSON, whatever, it logs and returns null. Nothing it does can take down a scan cycle that is also delivering email for everyone else.&lt;/p&gt;

&lt;p&gt;The consumer side is one line, and it is the line I am most careful about:&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;// Gate on threshold only when we actually have a verdict; AI failure =&amp;gt; filters-only.&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;verdict&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;matching&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the verdict is missing, the job passes. Being wrong in a direction that sends a slightly off job beats being wrong in a direction that sends nothing, because a user who gets a mediocre alert shrugs, and a user who gets no alerts churns.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt is mostly a scoring rubric
&lt;/h2&gt;

&lt;p&gt;The prompt itself is short and unglamorous. It asks for exactly two things: an integer from 0 to 100, and one sentence of at most eighteen words naming the decisive factor.&lt;/p&gt;

&lt;p&gt;The part that earned its keep is the rubric. Without explicit bands, scores cluster in a mushy 70 to 85 range and a threshold becomes meaningless. So I define the bands and tell the model to be decisive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;90-100 perfect fit: core expertise AND exactly the kind of work they want
70-89  strong fit: clearly within their skills, only minor mismatches
50-69  decent fit: plausible, but notable skill gaps
30-49  weak fit: tangential; they could stretch to it but shouldn't
0-29   poor fit: wrong domain, or serious red flags
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default alert threshold is 55, which users can move.&lt;/p&gt;

&lt;p&gt;Two other things I would repeat on any project like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use structured outputs.&lt;/strong&gt; The response is constrained by a JSON schema with &lt;code&gt;score&lt;/code&gt; as an integer and &lt;code&gt;reason&lt;/code&gt; as a string. I still clamp and round on the way out, because trusting a schema and validating anyway costs nothing, but I stopped writing regex to dig JSON out of prose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the explanation part of the product.&lt;/strong&gt; The one sentence reason is not debug output. It goes straight into the alert email under the job, and the score sorts the email so the best fit is at the top. A number by itself asks the user to trust you. A number plus "strong Retell and n8n overlap and a verified client, but the budget sits below your $50/hr floor" lets them check your work in two seconds. That sentence does more for retention than any accuracy improvement I could buy by upgrading the model.&lt;/p&gt;

&lt;p&gt;Speaking of which: the default model is Haiku 4.5, and the model name is an environment variable. On my own estimate a score costs somewhere around a tenth of a cent, which is the whole reason a free tier can exist. If match quality ever becomes the binding constraint instead of cost, I change one env var and every future score is smarter. I have not needed to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell someone adding an LLM to a background job
&lt;/h2&gt;

&lt;p&gt;The question that occupied me for a week was not which model. It was how many calls I could avoid making.&lt;/p&gt;

&lt;p&gt;Deterministic filters first, because they are free and they are honest. Cache on the natural identity of the work, not on the request. Give every user a hard ceiling and define what happens underneath it. Let the model fail into the non-AI path rather than into an error.&lt;/p&gt;

&lt;p&gt;Do that and the model becomes what it should be: the last, smallest, most expensive step in a pipeline that already worked without it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>typescript</category>
    </item>
    <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>
  </channel>
</rss>
