Slack bots & approvals

An AI Bot That Watches Our Slack Channel and Diagnoses Its Own Errors

A bot that reads our bots' failures and proposes patches it may not apply, plus the morning a DNS flood and a launchd PATH trap silenced three bots.

· by OI

When you run bots without a developer, the question isn’t “will they fail?” (yes, with enthusiasm) but “who notices?” For months the honest answer was “me, eventually, by scrolling”. So I built a bot whose entire job is to read the other bots’ failures and tell me what they mean. A bot for the bots. It’s bots all the way down.

Error watcher

One bot on fire, another holding a magnifying glass

What it does

How the watcher works

  1. Reads the Slack channel where every bot posts its errors.
  2. For each new error, opens the relevant source file and the log around that timestamp.
  3. Writes a diagnosis in plain language: what failed, the likely cause, which file and function.
  4. Proposes a patch as a diff.
  5. Posts all of that back to the channel. And stops.

Step five is the entire design. The watcher cannot apply a patch. It has no write access to the running code and no path to deploy anything. A human reads the proposal, decides, makes the change.

People ask why I don’t let it “just fix things”. Because a bot that edits the code other bots run on, unattended, at 2am, is a bot that will eventually break all seventeen of them simultaneously, with a confident commit message and a cheerful emoji. I’ve met that bot in my dreams. It does not get write access.

What the watcher posts

The morning three bots went quiet

One morning in September the claims logger stopped posting. The usage-amount bot stopped answering. The watcher posted one diagnosis and then also went quiet, which felt personal. Three bots, one process, zero information. Two unrelated things had broken, and from Slack they looked like one thing. The order you diagnose them in turned out to be the whole lesson.

Failure 1: the DNS flood

The watcher’s one diagnosis was the useful one. The log was enormous, and 90% of its lines were the same error: the support platform’s API hostname failing to resolve. Not a 500. Not a timeout. Name or service not known, every few seconds, each with a full Python stack trace, each identical to the last.

That morning, in numbers

The machine’s DNS resolver was failing intermittently. Every poll hit it, every failure logged twenty lines, and the retry interval was short. Real errors from the other bots were in there somewhere, buried under an avalanche of the same sentence. The watcher proposed backing off and logging one line instead of a trace. It was right, and we went one step further:

  • On resolver failure, back off (30 s, then 60, then 120) instead of retrying immediately like an over-eager puppy.
  • Log one line per failure, not a trace. The trace is identical every time and tells you nothing after the first.
  • A public resolver as fallback in the machine’s network settings.

Failure 2: the PATH trap, hiding under the flood

Quieting the log was debugging in its own right. With one line per failure, errors that had been buried for days showed up at once. One of them: a bot that worked perfectly from a terminal had died silently as a launchd service. It logged “starting” and then did nothing. No error. Just a bot sitting in the dark.

launchd does not run your shell profile. The PATH it hands a service is minimal to the point of rudeness. The bot called the Claude CLI by its bare name, claude, which the shell could find and launchd could not. The subprocess failed with “command not found”, and the bot’s own error handling swallowed it whole without chewing.

Two fixes, both permanent. Full paths everywhere: the code resolves the CLI’s absolute path once at startup and never calls the bare name again.

Never call the bare name

def claude_cli():
    # never call the bare name 'claude' — launchd has no PATH
    for p in (os.environ.get('CLAUDE_CLI'), '/opt/homebrew/bin/claude',
              os.path.expanduser('~/.local/bin/claude'), shutil.which('claude')):
        if p and os.path.exists(p):
            return p
    raise RuntimeError('claude CLI not found; set CLAUDE_CLI')

And an explicit PATH in the plist, so anything else the bot shells out to behaves the same under launchd as in a terminal:

<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>

The rest of the launchd traps are in launchd vs Docker.

The order to check next time

  1. Is the process running? Activity Monitor or launchctl list. If not, it’s launchd, not the bot.
  2. Is the log flooding? tail -200 and look for one line repeating. If it is, you have not yet seen the real error. You’ve seen the weather.
  3. Does the bot work from a terminal but not as a service? PATH, environment, or login state. Always one of those three. Always.
  4. Only now read the bot’s own errors, or the watcher’s diagnosis of them.

I did it in the wrong order and spent a morning reading stack traces that were all the same stack trace. That morning is why this list exists.

What it gets wrong

It over-diagnoses. A transient network blip gets the same thoughtful three-paragraph essay as a real bug, like a doctor who orders an MRI for a paper cut. I added a rule: if the same error hasn’t recurred within 30 minutes, post one line, not a diagnosis. It also occasionally proposes a patch to the wrong layer, fixing a symptom in the bot when the cause is in a shared module. A human catches that, because a human reads every proposal. That’s the deal.

Where alerts go now

Early on, errors went to a channel. Within a week it was wallpaper. People muted it. I muted it. The change that actually mattered wasn’t the watcher at all; it was routing. Every bot error now goes to one person’s DM: mine. A red badge on a DM doesn’t become wallpaper, however hard I try. The channel is reserved for the watcher’s diagnoses, which are worth reading, not for raw errors, which aren’t.

Second routing rule: when a bot alert concerns a specific support case, it tags the agent who handled it plus me. Not the channel. Not @here. The person who can act, and the person who owns the bot. Two people. Everyone else keeps their afternoon.

The part people skip

The watcher only works because every bot already logs in a consistent shape: a prefix per bot, a timestamp, the operation, the error. If your bots log free-form prose, the watcher has nothing to grab onto and will produce free-form prose back at you. Fix the logging first. Then build the watcher. I did it in the other order, briefly, and learned things.

One line from me

A bot for the bots is only as good as the logs it reads. Give every bot the same log shape and the watcher gets useful instead of chatty.

Why it runs on Opus when most bots don’t is in Haiku, Sonnet or Opus.