Stop being the boss you hate: build AI functions, not chatbots
A talk by Michael Carroll, founder of Coolhand Labs, given at XO Ruby Toronto in October 2026.
About the speaker
Michael Carroll has 15 years in Ruby and has founded three companies. He was on the founding team at RubiconMD, a Ruby health-tech company acquired and now part of CVS Health, then VP of Engineering at Teladoc Health, overseeing 500+ engineers through the pandemic. As he puts it: “One unifying theme of my career? Saving myself and others from the drudge work of engineering, so we could focus on building products.” He now runs Coolhand Labs, which removes that same drudge work from running AI agents — catching silent failures, turning human feedback into code fixes, and right-sizing models — and writes The Everything Engineer.
Two work environments
Consider two work environments. In environment A, the task is clearly defined, you have a list of tools for each part of it, there's help when you need it, and the feedback is about outputs and outcomes. In environment B, the task is somewhat defined, you discover the tools as you go, a manager pops in now and then to check on your work, and often there's no feedback at all. Ask a room which they'd rather work in and the answer is close to unanimous: A.
Environment B is how most of us work with our chatbots. The task is fuzzy, the context is re-supplied by hand every conversation, and we hover over every turn. Good thing your AI chatbot doesn't have access to Glassdoor — yet. The way out isn't a better model or more patience. Environment A is the functional AI paradigm.
Software is deterministic. AI is probabilistic.
Two functions can share the same three properties — inputs, rubrics, an
evaluable output — and still be nothing alike to build. add_numbers(1,
4) is deterministic: testing it is binary and reliable, its business
logic abstracts cleanly out of the surrounding code, and it's fully separable
from the language it's written in. An AI function like help_center
is probabilistic: testing it is non-binary and flakey, its business logic is
often the context itself rather than something you can pull out into a spec,
and it's only somewhat separable from the LLM model running it. That's the one key
difference this talk is really about — not the syntax, but how you know it's
working.
An agent, then, is a function whose steps aren't all pre-defined: it gets triggered by a schedule, an event or another agent; it loads its baseline context and decides what data and tools it needs; and it produces an output. Notice what's missing from all three steps. A human should never talk to the agent. The only human involvement is evaluating the output.
A case study: keeping our help center current
The rest of the talk is one real function at Coolhand Labs, told in seven beats. The job is unglamorous and never urgent, which is exactly why it rots: keeping public help center articles describing what the product actually does today. It's worth having at all for three reasons: it helps users — and, increasingly, their agents — understand product features; it's a source of truth our own coding agents cite; and it's better discovery by search engines. The number that made the case internally was one I didn't expect: making the help center public increased our Google traffic 3×.
First, the problem. Our AGENTS.md says, in bold,
that keeping the help center current is part of shipping a user-facing change
and not a follow-up — check whether an article documents the thing you
changed, and update it in the same piece of work. Every coding agent in the
repo reads it. It works — sometimes, sort of: getting the voice and the goals
right bloats the prompt and confuses the agent; merge conflicts spike as
agents step on each other's edits; I end up manually double-checking whether
it even ran; and there's no way to know what any of it costs, so there's no
way to know whether it's worthwhile. An instruction is not a function — it
fails all three tests above.
Second, turn it into a function. Take it out of the chatbot
and break it into three pieces: propose an idea, write the update, review it.
A nightly coordinator fans out one job per source kind, and each one digests
every allowlisted file with SHA256 into a source index. That's deliberately
not git log --since: from a Sidekiq worker, git depends on the
deploy image's clone depth, ref layout and detached-HEAD state, none of which
the build guarantees, while a digest per path answers the same question from
data we control and survives a rebuild that rewrites mtimes. On top of that
index, a signal collector builds the agent's brief out of six deterministic
queries — sources whose digest changed, articles whose prose predates a change
to a source they cite, articles past their freshness window, sources no
article has ever been derived from, features we announced since the last run,
and the currently open ideas so it doesn't propose a duplicate. Because the
brief is deterministic, the coordinator can
ask whether tonight's run is worth paying for before paying for it: if the
report is empty, the model never runs and the help center costs nothing that
week.
The propose step gets twelve tools, counting the shared ones. All of them read; the only one that creates anything is the tool that proposes an idea — it cannot write an article at all. The capability boundary is the tool list rather than a sentence in the prompt asking it politely not to publish, because an agent can ignore an instruction but it cannot call a tool it doesn't have. Its output is a database row with a status, a kind, a source and a deterministic dedupe key, not a paragraph: reviewable, queryable, and carrying provenance. One input surprised me: our agents have a "wildcard" tool that promises to fix whatever is blocking them; across 489 records it was a placebo that never did anything, and they used it anyway. What they wrote turned out to be a documentation backlog, so agent escalations are now a first-class signal in the brief. Accepting an idea is two lines of code that dispatch the write step — the interesting part was never the dispatch, it's everything that decided the dispatch was worth doing.
Third: it's still wrong sometimes. Probabilistic means it will be, no matter how good the pipeline is. How do we get to trusting it?
Fourth, add review gates on each function, and set a baseline. There are two approval gates. Agent-proposed ideas land pending and need human triage, because acceptance is what authorizes paid LLM spend on a writer run. And a rewrite of an already-published article stages a revision and waits, because these pages are public, indexed, and quoted verbatim by answer engines. Both gates are correct, and I don't want to remove either — but correct isn't the same as permanent. The baseline they're earning is the same one a good CFO works to: they don't audit every invoice, they true up the numbers periodically, and when the numbers don't true up they fix the process so it can't happen twice.
Fifth: once the baseline is high enough, suspend the gates — or sample them at a much lower rate. Everything gets reviewed early, while there's no baseline yet. A fraction gets reviewed once accuracy holds up. And review goes back to everything the moment something changes that needs it back: a new source, a new prompt, a spike in rejections. I can't defend the exact sampling number we picked — that's on the open-questions list — but I can defend the shape: review load should track how much you actually trust the function that week, not stay fixed forever.
Sixth: but what about when people forget to review? Sampled
or not, the ideas still sat in a Rails admin table, and I opened it when I
remembered to — the same manager from the start of this talk, wearing a
different hat. A well-calibrated sample rate still assumes someone opens the
queue, and a CRUD table has no cadence of its own; it waits. So we changed the
review surface, not the gate. When approvals become the bottleneck, the
instinct is to remove the approval; almost always the right move is to make
reviewing cheap enough that the approval stops costing anything. A review
surface needs three things a CRUD table doesn't have: serialized, so you see
one item at a time in order rather than grazing a list for disasters;
deduplicated, so it never hands back something you already judged; and
time-boxed, with a queue sized so finishing is possible. (Sampling by
maturity, from the previous beat, is what decides how much lands in the queue
in the first place.) So the review queue became its own endpoint under a
/captain namespace — captainslog — authenticated
with a bearer token from a "Sign in with Coolhand" OAuth flow rather than an
admin session cookie, with the dedup filter applied server-side so two devices
can't disagree about what's left.
Whichever direction the feedback comes from, it only counts if it becomes a change. Rejecting the same proposal three times is a rubric the agent never got. An edit that doesn't turn into a prompt fix, a tool fix or a change to what the agent is allowed to read improved exactly one output.
Seventh: where this goes next
Plenty is still unsolved. What's the right sampling rate — everyone picks a number that feels right and nobody can defend it. How do you detect drift when the output is prose and there's no diff to review? What does a good rubric look like for a judgement-heavy function, and who writes it — the engineer or the domain expert? And when is a function mature enough to have no gate at all? We are not there. Saying so plainly is what keeps this a report from the middle of the work, not a victory lap.
So the next time working with AI feels like a slog, look at what you actually built. If you're sitting there typing yes, yes, yes, you didn't build an AI function. You hired a worker to micromanage.
Related reading: The Captain Strategy: stop using AI to become the boss you hate and Captains don't steer every AI agent turn — they read the logs, in The Everything Engineer. The agent-feedback material comes from Your AI Agent Has Notes.