<?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: Scott Lee</title>
    <description>The latest articles on DEV Community by Scott Lee (@scott_lee_yo).</description>
    <link>https://dev.to/scott_lee_yo</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%2F4102089%2Fc94d0b81-b36b-4baa-ac08-67289e8cfdb8.png</url>
      <title>DEV Community: Scott Lee</title>
      <link>https://dev.to/scott_lee_yo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/scott_lee_yo"/>
    <language>en</language>
    <item>
      <title>Building Camrade</title>
      <dc:creator>Scott Lee</dc:creator>
      <pubDate>Mon, 31 Aug 2026 23:52:12 +0000</pubDate>
      <link>https://dev.to/scott_lee_yo/building-camrade-3ldb</link>
      <guid>https://dev.to/scott_lee_yo/building-camrade-3ldb</guid>
      <description>&lt;p&gt;Camrade is a voice-first photo studio. You talk to it about the photograph in front of you, it reads the frame, edits it, and learns your photography style. This is not a tour of how Camrade works but about the failures, because almost every decision worth describing came out of one, and the worst of them were completely silent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wine bottle
&lt;/h2&gt;

&lt;p&gt;Early on, Argus told me about a wine bottle. There was no wine bottle.&lt;/p&gt;

&lt;p&gt;I had given the voice model a system instruction that amounted to "say what you see." What I had not given it was any way to see. No image was ever sent to it. So it did what a language model does when asked to describe something it knows nothing about: it produced a plausible description, in exactly the confident tone it used for everything else.&lt;/p&gt;

&lt;p&gt;That bug was worth more than most features. Not because it was hard to fix but because of what it exposed. A confident wrong answer is not a slightly worse right answer. It is worse than silence, because it contaminates everything around it. Once the agent had described a bottle that was not there, I had no way to know which of its other observations to trust.&lt;/p&gt;

&lt;p&gt;Nearly every design decision after that was about making the agent's uncertainty legible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blind by design
&lt;/h2&gt;

&lt;p&gt;The fix was not a better prompt. It was making the failure structurally impossible.&lt;/p&gt;

&lt;p&gt;The voice model now cannot see. No image is sent to it, ever. To say anything about the photograph it has to call &lt;code&gt;analyze_photo&lt;/code&gt;, which returns a critique produced by a different model that did look at the pixels. The system instruction says so in as many words:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Saying "I see a..." without having called &lt;code&gt;analyze_photo&lt;/code&gt; is the worst thing you can do here,&lt;br&gt;
because the photographer will believe you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That split turned out to be load-bearing. &lt;code&gt;gemini-live-2.5-flash&lt;/code&gt; handles the conversation and nothing else — it is a transport. It selects a tool and relays the result. Every judgement, every critique, every edit instruction comes from &lt;code&gt;gemini-3.5-flash&lt;/code&gt;. The live model is a 2.5-generation model, so it is precisely the thing that should not be forming opinions about anyone's photographs.&lt;/p&gt;

&lt;p&gt;The same principle runs through the rest. Capture data the file never recorded stays null rather than being estimated. Every suggestion carries a badge saying whether it rests on measured settings, on what the model can see, or on your profile. If it doesn't know, it says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three failures that made no noise
&lt;/h2&gt;

&lt;p&gt;The wine bottle was loud, but the expensive bugs did the opposite.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;catch&lt;/code&gt; block hid a bug for most of the project's life. The voice agent fetched the my profile at session start without an auth header. That request redirects to the sign-in page, which answers 200 with HTML. So &lt;code&gt;res.ok&lt;/code&gt; was true, the JSON parse threw, and a&lt;br&gt;
&lt;code&gt;catch&lt;/code&gt; commented &lt;em&gt;"No profile is a normal cold start, not a failure"&lt;/em&gt; absorbed it. The agent had never once received the profile, not in any session, ever. I only found it while tracing an&lt;br&gt;
unrelated bug; nothing in the app's behavior had ever pointed at it. The real bug was not the missing header. It was a &lt;code&gt;catch&lt;/code&gt; that treated &lt;em&gt;broken&lt;/em&gt; and &lt;em&gt;empty&lt;/em&gt; as the same thing.&lt;/p&gt;

&lt;p&gt;I spent four hours debugging code that was never running. The agent would not speak first when prompted. I read the SDK internals twice, reasoned carefully about two different API calls, swapped between them. Nothing changed. The evidence made no sense: the browser was demonstrably sending the right message, and the server behaved as if it hadn't. Vite's hot reload covers the app bundle but not &lt;code&gt;server.mjs&lt;/code&gt; or &lt;code&gt;live-bridge.mjs&lt;/code&gt;, which Node loads directly at boot. Every fix's browser half reloaded correctly while its server half kept running four-hour-old code. I found it by comparing the process start time against the file's modification time. The dev script now watches those files explicitly, and there is a comment at the top of &lt;code&gt;server.mjs&lt;/code&gt; explaining it, because that failure is silent and extremely convincing.&lt;/p&gt;

&lt;p&gt;A metric would have counted nothing forever. While wiring up Cloud Monitoring I planned a metric for how often the one-render-at-a-time limit turns someone away. Before writing the filter I checked&lt;br&gt;
whether the app actually emitted that event. It didn't. The filter would have been syntactically perfect and the chart permanently empty and an empty chart looks exactly like a healthy one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making "it learns you" falsifiable
&lt;/h2&gt;

&lt;p&gt;Every agent claims it learns you. Almost none can show it.&lt;/p&gt;

&lt;p&gt;Camrade has a test that shows the same photograph to the model twice, under two opposite taste profiles, and asserts the advice inverts. It does: &lt;em&gt;"warm up the tones / lift the black point"&lt;/em&gt;&lt;br&gt;
versus &lt;em&gt;"crush the shadow tones / cool down the colour balance."&lt;/em&gt; Same photo, same model, opposite answers, with only the profile differing.&lt;/p&gt;

&lt;p&gt;That test exists because the claim is otherwise unfalsifiable. A model asked "does this feel personalized?" will always say yes.&lt;/p&gt;

&lt;p&gt;The mechanism matters too. Every edit you ask for is classified onto an axis (warmth, contrast, blacks, crop) and the profile is &lt;em&gt;counted&lt;/em&gt; from those labels rather than summarized. Confidence is arithmetic: how much evidence exists multiplied by how consistent it is. Six contradictory edits cannot read as confident, because the number is not something the model chose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Probe, don't trust.&lt;/strong&gt; Two of the most expensive bugs came from believing documentation about which models exist and where they are served. &lt;code&gt;gemini-3.1-flash-live-preview&lt;/code&gt; does not exist on Vertex AI&lt;br&gt;
at all, it 404s everywhere. Fifteen minutes of probing would have saved hours of reasoning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know where your reload boundary is.&lt;/strong&gt; Hot reload covering &lt;em&gt;some&lt;/em&gt; of your code is more dangerous than covering none, because half your changes take effect and the evidence stops making sense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;catch&lt;/code&gt; that cannot distinguish &lt;em&gt;broken&lt;/em&gt; from &lt;em&gt;empty&lt;/em&gt; will hide a bug indefinitely.&lt;/strong&gt; Both of the longest-lived bugs here were silent failures, not crashes. Logging the difference costs one line.&lt;/p&gt;

&lt;p&gt;The through-line, in the end, was simpler than I expected: being useful and being honest turned out to be the same engineering problem.&lt;/p&gt;

&lt;p&gt;This post was written for the &lt;a href="https://allthingsagentichackathon.devpost.com" rel="noopener noreferrer"&gt;#AllThingsAgentic Hackathon&lt;/a&gt;. Follow along to see how I do!&lt;/p&gt;

</description>
      <category>allthingsagentic</category>
      <category>allthingsagentichackathon</category>
    </item>
    <item>
      <title>How Argus was built</title>
      <dc:creator>Scott Lee</dc:creator>
      <pubDate>Mon, 31 Aug 2026 23:49:10 +0000</pubDate>
      <link>https://dev.to/scott_lee_yo/how-argus-was-built-2joo</link>
      <guid>https://dev.to/scott_lee_yo/how-argus-was-built-2joo</guid>
      <description>&lt;p&gt;Argus is an agent that watches San Francisco's civic apparatus and acts on what it finds.&lt;/p&gt;

&lt;p&gt;Following one issue through city government is a seven-step chore: watch a dozen publishers that share no format or schedule, notice when an agenda is quietly amended, open the PDF packet, work out whether any of its hundred-odd items touches something you care about, get the hearing into your calendar with the right room number, find out afterwards what was decided, and repeat weekly.&lt;/p&gt;

&lt;p&gt;Steps one to four are unpaid research. Steps five to seven are the part anyone actually wants and nobody ever reaches. Argus does all seven on a schedule, and writes to a real Google Calendar and sends real email at the end of it.&lt;/p&gt;

&lt;p&gt;The three issues I track are ordinary: &lt;strong&gt;road improvements, bike lanes, and schools.&lt;/strong&gt; Nothing about them is exotic. That's the point, the information isn't secret, it's just that keeping up with it is a part-time job.&lt;/p&gt;

&lt;h1&gt;
  
  
  The bugs that don't announce themselves
&lt;/h1&gt;

&lt;p&gt;I asked Argus who the mayor of San Francisco was. It didn't know.&lt;/p&gt;

&lt;p&gt;The answer was already in its database. The city's department directory, a page Argus had fetched, parsed, embedded and stored contains the sentence "Daniel Lurie is the 46th Mayor of the City and County of San Francisco." I could select it with a SQL query in a few seconds.&lt;/p&gt;

&lt;p&gt;The problem was retrieval. Postgres full-text search ANDs its terms, and that directory is 20,000 characters listing roughly a hundred departments, most of which contain the word "Mayor": Mayor's Office of Housing, Mayor's Office for Victims' Rights, and so on. On term frequency alone the catalogue outranked the mayor's own profile page. Worse, the snippet extractor then picked the &lt;em&gt;densest&lt;/em&gt; cluster of matches, which was some unrelated office. So the agent received a document that technically contained the answer, along with an excerpt that didn't, and correctly reported that it couldn't find anything.&lt;/p&gt;

&lt;p&gt;That is the failure mode I spent most of this project learning to recognize. A missing fact announces itself. An unreachable one looks exactly like a model that doesn't know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two searches, both wrong on their own
&lt;/h2&gt;

&lt;p&gt;The fix for the mayor bug was not a better query. It was accepting that keyword search and vector search fail in opposite directions.&lt;/p&gt;

&lt;p&gt;Keyword search demands every term appear. "Traffic citations" found nothing in a document headed "Traffic Violations". Vector search understands that those mean the same thing, but has no notion of an exact name, and will happily rank a merely-similar document above the one actually about the thing you asked for.&lt;/p&gt;

&lt;p&gt;My first attempt ran keyword search and fell back to vectors only when it returned zero rows. That version was worse than either alone, because it hid the exact case that mattered: a weak but non-empty match blocks the fallback entirely. The department catalogue always matched something, so the vector search, which finds the mayor's profile at a cosine distance of 0.27, never ran at all.&lt;/p&gt;

&lt;p&gt;Now both run every time and are merged by reciprocal rank fusion, which needs no shared scale between a &lt;code&gt;ts_rank&lt;/code&gt; and a cosine distance because it only uses each result's position in its own list.&lt;/p&gt;

&lt;p&gt;The distance cutoff is measured rather than guessed. Across the real corpus, on-topic questions land at 0.27–0.34 and off-topic ones ("weather in Tokyo", "renew a passport in Ireland") at 0.51. The threshold sits at 0.42, in the gap, so an unrelated question returns nothing instead of a confident citation to whatever happened to be nearest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some doors are closed on purpose
&lt;/h2&gt;

&lt;p&gt;Two of the sources I wanted refused me. BoardDocs returns 403 to any client that isn't a browser. SFCTA sits behind a Cloudflare challenge.&lt;/p&gt;

&lt;p&gt;Both would have taken about ten minutes to defeat with a spoofed user-agent. I decided not to. A 403 to an honest client is a publisher saying no, and the fact that the door opens if you lie about who you are doesn't make it an invitation.&lt;/p&gt;

&lt;p&gt;So those sources are supplied by hand and run through the &lt;em&gt;same&lt;/em&gt; deterministic parsers as everything else, recorded with &lt;code&gt;kind='manual'&lt;/code&gt; so their provenance stays visible. That matters: unlike a polled source, nothing re-checks them, and they can go stale silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invoice you can't see
&lt;/h2&gt;

&lt;p&gt;Argus records what every model call costs as tokens, latency, and dollars.&lt;/p&gt;

&lt;p&gt;Building that turned up a trap. Thinking models bill their reasoning tokens at the output rate, but report them in a separate field from the completion. If you cost a call from the two obvious numbers, you undercount. On a real measured call: $0.000557 by the naive arithmetic, $0.001986 actually owed. A 3.6× under-report, on every single call, silently.&lt;/p&gt;

&lt;p&gt;The rule that came out of it: an unpriced model records &lt;code&gt;NULL&lt;/code&gt;, never &lt;code&gt;0.00&lt;/code&gt;. A zero sums into a total and reports spending that never happened; a NULL means "I don't know" and stays visibly unknown.&lt;/p&gt;

&lt;p&gt;And then something unexpected: the total model spend across the&lt;br&gt;
entire project is $0.91. Two hundred and eighty-eight calls, thousands of agenda items judged, dozens of meeting outcomes extracted from transcripts, all under a dollar. The models were never the expensive part. An always-on Postgres instance and one warm container will cost more per month than every Gemini call made during the whole build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule underneath all of it
&lt;/h2&gt;

&lt;p&gt;One principle did more work than any other: the model writes content; code decides actions.&lt;/p&gt;

&lt;p&gt;No model call creates, moves, or cancels a calendar event. The model can raise a proposal card; the write happens when a person presses a button, or when a deterministic delivery step runs. A model that decides to "just do it" has nothing to call.&lt;/p&gt;

&lt;p&gt;The second half is less obvious and catches a subtler failure: the model names a thing, and the server supplies the facts. A calendar proposal passes a &lt;code&gt;meeting_id&lt;/code&gt; and nothing else. The time, the place and the title are read from the database. This isn't about trust, it's that a model asked to restate a date will occasionally restate it slightly wrong, and a hearing on the wrong Tuesday is worse than no hearing at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is now
&lt;/h2&gt;

&lt;p&gt;Over a thousand meetings. Thousands of agenda items. Tens of thousands of transcript segments. Hundreds of reference&lt;br&gt;
documents. Thousands of issue matches judged by a model, Over a hundred confirmed with a stored rationale. Tens of meeting outcomes extracted from transcripts. Hundreds of tests. Less than a dollar spent.&lt;/p&gt;

&lt;p&gt;The honest open item: that matcher confirms 111 of 3,088 candidates, and I have no idea whether those are the right 111. Until there's a hand-labelled set of examples to measure precision against including deliberate&lt;br&gt;
vocabulary-overlap non-matches "it runs" is not the same as "it's right."&lt;/p&gt;

&lt;p&gt;That's the next thing. It's less fun than building the pipeline, and it's the only test that says whether any of this is useful rather than merely working.&lt;/p&gt;

&lt;p&gt;This post was written for the &lt;a href="https://allthingsagentichackathon.devpost.com" rel="noopener noreferrer"&gt;#AllThingsAgentic Hackathon&lt;/a&gt;. Follow along to see the results!&lt;/p&gt;

</description>
      <category>allthingsagentichackathon</category>
      <category>allthingsagentic</category>
    </item>
  </channel>
</rss>
