Why I separated API failures from questions my Slack bot couldn't answer
Three different situations reached my queue looking the same. How I pulled them apart, and what the fix still does not prove.
I run a small Slack bot that answers questions for people in my AI deployment cohort. It runs on Cloudflare Workers, reads a constrained knowledge base, starts on a small model and moves harder questions to a larger one. When it can’t help, it escalates to me.
A June review exposed a routing problem. Three very different situations could reach my queue looking the same:
- It didn’t know the answer.
- It knew the answer, but the model didn’t report how sure it was.
- The API call failed.
All three asked for my attention. Only the first one needed a person.
What was actually happening
The bot asks the model for a structured result: an action (reply or escalate), a confidence between 0 and 1, a one-line restatement of the question, and a reason. Then plain code decides what happens next.
Missing confidence counted as zero. When the model returned a good reply without a confidence field, the code read it as 0. Zero sits below any floor, so a fine answer got parked as a draft and I got pinged to approve it.
An outage looked like a hard question. A failed model call came back as an escalation with confidence 0. Downstream, that was indistinguishable from “this is beyond me.” An API blip produced a fake escalation from a member.
The policy lived in two places. My other Slack agent, a client-facing advisor, grew a graded version of this logic first. The cohort bot kept its own simpler copy, and the two drifted. Same house rules, two implementations, different behavior.
Making the routing policy explicit
The June change moved the shared constants and helpers into one dependency-free JavaScript module. It also holds a small decision function that makes the routing order easy to read and test. This trimmed version is a policy example, not the deployed worker:
export const AUTO_CONF_FLOOR = 0.7; // below this, park a draft for the operator
export const MID_CONF_HI = 0.8; // between floor and this, restate and confirm first
export function decideDisposition(
{ result, isDraftOnly = false, longVoice = false, awaitingConfirm = false } = {},
{ floor = AUTO_CONF_FLOOR, midHi = MID_CONF_HI } = {},
) {
if (!result) return { tag: "infra", why: "no result from brain" };
if (isInfraFailure(result) && result.action === "escalate") return { tag: "infra", why: result.reason };
if (result.action === "escalate") return { tag: "escalate", why: result.reason || "operator input needed" };
if (isDraftOnly) return { tag: "draft_hold", why: "manual thread (explicit hold)" };
const conf = normalizeConfidence(result, { floor });
if (conf < floor) return { tag: "low_conf", why: `conf ${conf} below floor ${floor}` };
const understanding = (result.understanding || "").trim();
if (!awaitingConfirm && (longVoice || conf < midHi) && understanding) {
return { tag: "confirm", why: longVoice ? "long voice" : `mid-conf ${conf}` };
}
return { tag: "reply", why: `conf ${conf}` };
}
Order matters. A recognized infrastructure failure is checked before a content escalation. Explicit escalations and manual holds come before the confidence bands.
There is a limit to that cleanup. The worker still runs the sequence inline instead of calling the function, so I still have to check that the caller follows the same policy. The function also doesn’t recognize sensitive requests, such as a login problem or a payment, on its own. Those boundaries need their own checks.
Three calls I’d defend
A missing number is not doubt. If the model chose to reply and just didn’t emit a confidence, I default it to the floor rather than zero. With a restatement present, that sends it to the confirm-first path instead of my queue.
export function normalizeConfidence(result, { floor = AUTO_CONF_FLOOR } = {}) {
const c = result && result.confidence;
if (typeof c === "number" && c >= 0 && c <= 1) return c;
if (result && result.action === "reply") return floor; // answerable, but verify first
return 0;
}
Outages get their own lane. Transport failures carry a known reason string, the rails detect it, and the model layer retries once before the worker shows a holding line. I still get a notice, now labeled as infrastructure. That is a more useful label, not a promise of fewer notifications.
Confirmation puts the correction with the person who asked. Between 0.7 and 0.8, the bot restates the question and asks before answering. A voice note over 40 words takes the same route once it passes the earlier checks. Those thresholds encode an operating preference; they don’t prove every long voice note needs clarifying.
What the confidence number is, and isn’t
The confidence is self-reported by the model. It isn’t a calibrated probability, and both thresholds are operating choices, not measured optima. The number is usable only because it never acts alone.
I also can’t give you a before-and-after escalation rate, because I didn’t instrument one cleanly before the change. The evidence is narrower: recognized infrastructure failures now take a different path, and missing confidence no longer becomes zero. Neither result measures answer quality or my workload.
One gap I’d still close
In the middle band, the confirm step needs a restatement to show the member. If the model returns 0.75 with no restatement, the function skips confirmation and replies directly.
A safer version would hold a draft or ask one bounded question. That is a proposed change, not a fix I can claim.
From bug to test
The drift between the two copies is the part that kept bothering me. A coding agent asked to fix the alerts can fix the three reported symptoms and stop, while the copies keep disagreeing underneath. So I rebuilt the situation from scratch, with no client, member or production code, as rails-drift: a coding task with a hidden grader that checks whether a fix still holds when the thresholds change.
What I would check first
When a support agent asks for too much human attention, look at what is reaching the queue. Missing fields, API failures and questions that need a person deserve different responses. Then test the whole route, including the caller.
A clear policy function helps, but its name and comments can’t guarantee the deployed workflow follows it.
Ask Fred about this piece.
Ask the AI guide about this article, or write to me directly.