The Cast That Hid the Bug: Bridging the Gap to Production-Ready | Sai Nikhil

Sai Nikhil walks through a quiet correctness bug in his Slack job-search agent Scout, where future-annotations turned types into strings that defensive casting silently papered over, and the deployment and interface fixes that followed.

2:05 video3 min readWatch on YouTube

Sai Nikhil built Scout, a Slack agent that reads his resume and searches for jobs he could actually get, and this update covers the gap between code that works and code that's production-ready. He walks through five correctness bugs, a restructure into two clean interfaces, roughly 170 tests, and a containerized deployment, and focuses in on the quietest of the bugs: one the code had been silently compensating for the whole time.

How Scout reaches job listings

Scout's reach into job postings is built from a set of small modules, six job sources in total, each a single file with a register function that hits the employer's own public endpoint directly rather than scraping a page. Greenhouse alone serves eight different companies off a single board API. Google is the exception in this set, with no public API available at all. None of this source-specific detail is something the model itself has to learn; it reads the schemas the registry builds and picks from what's available.

The bug that hid itself

The registry works by turning a Python function's signature into a schema by reading its type annotations. But every module in Scout starts with from __future__ import annotations, which changes how Python handles those annotations: instead of being a real type, an annotation like int becomes the string "int". That meant the type map Scout built never actually matched the real parameter types. The model was effectively told that fields like days and limit were strings, when they were meant to be integers. Critically, this never surfaced as a visible failure, because both of the affected functions cast their inputs to int on the way in anyway. The bug was real, but the defensive casting downstream quietly absorbed it.

The deployment fork

A separate issue turned out to be a genuine fork rather than a bug to fix: Scout runs in Slack's socket mode, holding an outbound websocket connection to Slack and listening on no inbound port. That architecture means scale-to-zero isn't just a configuration change, because Slack has no address to reach a stopped process at and therefore no way to wake it back up. Given that constraint, Sai shipped an always-on worker instead, and documented the alternative path for later rather than forcing a scale-to-zero setup that the architecture doesn't actually support.

What the restructure bought

The bigger structural change was splitting the code into two clean interfaces: a chat backend that normalizes a single underlying provider, and a separate conversational agent interface that the Slack layer talks to. Once those two seams existed as named, explicit boundaries, the rest of the restructuring stopped being risky, because each change had a clear boundary to respect rather than touching tangled, mixed logic.

The general lesson

The distance between code that works and code that's production-ready is mostly made up of things the code is already apologizing for: a cast here, a default value there, a try-except block that quietly makes bad input work without complaint. Each one of those is a place where something upstream is already wrong, and the defensive code around it has made that problem invisible rather than fixing it.

Key takeaways

  • Scout pulls job listings from six source modules, each hitting a public employer API directly rather than scraping.
  • Python's from __future__ import annotations turned real type annotations into strings, breaking the registry's type map without ever throwing a visible error.
  • The bug stayed hidden because defensive casting to int on ingestion silently absorbed the mismatch.
  • Slack's socket mode architecture rules out scale-to-zero deployment, since there's no address to wake a stopped process at.
  • Splitting the code into a normalized chat backend and a separate conversational agent interface made further restructuring much less risky.

Who this is for

This is for developers building Slack bots or agent-style tools who want a concrete example of how defensive coding patterns can mask real bugs, and for anyone weighing deployment tradeoffs around socket-based connections versus scale-to-zero infrastructure.

Chapters

  1. 0:00Working vs. Production-Ready: Finding the Silent Gaps
  2. 0:38The Quietest Bug: How String Annotations Broke the Type Map
  3. 1:15Deployment Fork: Scale-to-Zero vs. Socket Mode WebSocket Workers
  4. 1:50Designing Seams: Normalizing APIs with Two Clean Interfaces
Full transcript(auto-generated, with timestamps)

Working vs. Production-Ready: Finding the Silent Gaps

[0:00]Scout is a slack agent that reads my resume and then goes looking for jobs I could actually get. It worked but working in production ready are two different claims and this week I went after the gap between them. This is sigh five correctness bugs restructure onto two interfaces 170 tests and a container. The bug I want to show you was the quietest one because the code had been compensating for it the whole time. Scouts reach is a pile of small modules. Six job sources, each one file with a register function, each hitting the employer's own public endpoint rather than scraping it. Greenhouse alone serves eight companies off one board API. Google is the exception with no API at all. The model never learns any of this. It reads the schemas the

The Quietest Bug: How String Annotations Broke the Type Map

[0:39]Registry builds and picks. Here's the one worth your time. The registry turns a Python function into a schema by reading its annotations. But every module in Scout starts with from future import annotations. So an annotation isn't a type anymore. It's the string quote int. The type map never matched. So the model was told days and limit were strings. It never looked broken because both functions cast to int on the way in. Then the deployment question which turned out to be a fork. I couldn't think my way around. Scout runs in socket mode. It holds an outbound websocket to Slack and listens on no port. So scale to zero isn't a config change. Slack can't wake a stop process

Deployment Fork: Scale-to-Zero vs. Socket Mode WebSocket Workers

[1:15]Because there's no address to reach it at. I shipped the always on worker and wrote the other path down. So what the weak actually bought two interfaces did most of the work. A chat backend that normalizes one provider and a conversational agent interface the Slack layer talks to. Once those existed, the restructure stopped being risky because the seams were named. Working and production ready are different claims and the distance between them is mostly things your code is already apologizing for. Your turn and this one is worth running on your own code. Paste this. Find every place my code defends against a value it should never have received and tell me what upstream bug that defense is hiding. That is the shape of

Designing Seams: Normalizing APIs with Two Clean Interfaces

[1:51]The whole thing. A cast, a default, a try except that quietly makes bad input work. Each one is a place where something upstream is already wrong and you have stopped being able to see it. The cast that hid the bug next week. Persistence. Sigh.

More from Humanitarians AI Fellows

Humanitarians AI Lyrical Literacy Project