DEV Community

Scott Lee
Scott Lee

Posted on

Building Camrade

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.

The wine bottle

Early on, Argus told me about a wine bottle. There was no wine bottle.

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.

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.

Nearly every design decision after that was about making the agent's uncertainty legible.

Blind by design

The fix was not a better prompt. It was making the failure structurally impossible.

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

Saying "I see a..." without having called analyze_photo is the worst thing you can do here,
because the photographer will believe you.

That split turned out to be load-bearing. gemini-live-2.5-flash 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 gemini-3.5-flash. 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.

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.

Three failures that made no noise

The wine bottle was loud, but the expensive bugs did the opposite.

A catch 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 res.ok was true, the JSON parse threw, and a
catch commented "No profile is a normal cold start, not a failure" absorbed it. The agent had never once received the profile, not in any session, ever. I only found it while tracing an
unrelated bug; nothing in the app's behavior had ever pointed at it. The real bug was not the missing header. It was a catch that treated broken and empty as the same thing.

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 server.mjs or live-bridge.mjs, 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 server.mjs explaining it, because that failure is silent and extremely convincing.

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
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.

Making "it learns you" falsifiable

Every agent claims it learns you. Almost none can show it.

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: "warm up the tones / lift the black point"
versus "crush the shadow tones / cool down the colour balance." Same photo, same model, opposite answers, with only the profile differing.

That test exists because the claim is otherwise unfalsifiable. A model asked "does this feel personalized?" will always say yes.

The mechanism matters too. Every edit you ask for is classified onto an axis (warmth, contrast, blacks, crop) and the profile is counted 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.

What I'd tell you

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

Know where your reload boundary is. Hot reload covering some of your code is more dangerous than covering none, because half your changes take effect and the evidence stops making sense.

A catch that cannot distinguish broken from empty will hide a bug indefinitely. Both of the longest-lived bugs here were silent failures, not crashes. Logging the difference costs one line.

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

This post was written for the #AllThingsAgentic Hackathon. Follow along to see how I do!

Top comments (0)