●COST — v2.1.239 folds the 1.1x US-only-inference premium for data-residency workspaces into /cost, the status line, and max-budget-usd estimates●BUDGET — Until now those workspaces saw estimates that ran below the real invoice. If you set a spend cap, it is worth reconciling this month's bill against your /cost history●FIXES — v2.1.240 is a fixes-and-reliability release, continuing recent work on MCP v2 connections, notification hooks, idle sessions on Linux, and Windows path validation●ACADEMY — Claude Academy is now open: a learning hub for safe, effective AI use, with courses, tutorials, badges, and personalized recommendations●PLATFORM — The computer use tool, browser use tool, Skills API, and Files API are now available on the Claude Platform, opening a path to move skill workflows off your local machine●PRICING — Claude Sonnet 5's introductory $2 per million input and $10 output ends August 31, with standard $3 and $15 pricing from September 1. Eight days to go●COST — v2.1.239 folds the 1.1x US-only-inference premium for data-residency workspaces into /cost, the status line, and max-budget-usd estimates●BUDGET — Until now those workspaces saw estimates that ran below the real invoice. If you set a spend cap, it is worth reconciling this month's bill against your /cost history●FIXES — v2.1.240 is a fixes-and-reliability release, continuing recent work on MCP v2 connections, notification hooks, idle sessions on Linux, and Windows path validation●ACADEMY — Claude Academy is now open: a learning hub for safe, effective AI use, with courses, tutorials, badges, and personalized recommendations●PLATFORM — The computer use tool, browser use tool, Skills API, and Files API are now available on the Claude Platform, opening a path to move skill workflows off your local machine●PRICING — Claude Sonnet 5's introductory $2 per million input and $10 output ends August 31, with standard $3 and $15 pricing from September 1. Eight days to go
Not Every Unreachable Connector Means the Same Thing in an Unattended Run
When an unattended task cannot reach a connector, the cause splits into three states: still connecting, unauthenticated, or genuinely absent. Each demands the opposite response. Here is the resolver that tells them apart.
The task had run cleanly the day before. When I retraced the same steps by hand, the connector answered immediately. Re-running the task succeeded. The next morning, the same single line came back. After a few rounds of this, the answer finally landed: the task had not been lying. At the instant it looked, that connector genuinely did not exist.
As an indie developer running more and more of my operations on scheduled tasks, I keep tripping over this family of failures — the ones whose outcome depends on what time you looked. What makes them nasty is that they all look identical from the outside. Faced with a connector that will not answer, a machine can only say "missing." But missing turned out to have more than one meaning.
An unreachable connector shows three different situations with the same face
Watching a real runtime bring its connectors up, I counted at least three distinct reasons a tool can fail to appear.
The first is that startup has not finished. A connection is being negotiated in the background, and the tool will show up in the inventory seconds later. Declaring it missing is not wrong so much as premature.
The second is that authentication has lapsed. The token expired, or the initial grant was never completed. The server itself is alive and its name is visible, but every call bounces. In an unattended context this one is decisive: the authorization handshake needs a human at a browser, so no amount of waiting will fix it.
The third is that the connector is simply not connected — removed from configuration, or no longer served in this environment. Waiting will not help here either, but the correct response differs from the second case: you look for an alternate path, or you drop the capability entirely.
One symptom, three correct behaviors. Collapse them into one and you end up giving up when you should have waited, and waiting when you should have given up.
State
What it really is
Does time fix it?
Default behavior
connecting
Handshake in progress
Usually, within seconds
Wait, within a budget
unauthenticated
Grant missing or expired
No — needs a human
Hand back immediately
absent
Not connected or not served
No
Fall back, or drop the step
The smallest resolver that tells the three apart
You need surprisingly little to classify. The tools currently usable, the servers still handshaking, and the servers waiting on authorization. Keep it a pure function and you can test it later.
// resolver.mjs — classify why a connector is unreachableexport const READY = "ready";export const CONNECTING = "connecting";export const UNAUTHENTICATED = "unauthenticated";export const ABSENT = "absent";// mcp__google-drive__search → "google-drive"// Built-in tools (Bash and friends) have no server, so "builtin"export function serverOf(toolName) { const m = /^mcp__([^_]+(?:_[^_]+)*?)__/.exec(toolName); return m ? m[1] : "builtin";}export function classify(toolName, snapshot) { const { readyTools, pendingServers, unauthenticatedServers } = snapshot; // 1. If it works, stop asking questions if (readyTools.includes(toolName)) return READY; const server = serverOf(toolName); // 2. Check auth FIRST. Check it later and you will misread // a permanent block as "it will show up eventually" if (unauthenticatedServers.includes(server)) return UNAUTHENTICATED; // 3. Still handshaking? Then do not conclude anything yet if (pendingServers.includes(server)) return CONNECTING; // 4. None of the above: it does not exist for this run return ABSENT;}
The order of those checks carries weight. Authentication is tested before the pending check because a server awaiting authorization frequently appears in the pending list at the same time. Flip the order and you have written code that waits forever on something that can never resolve. That single line ordering is what produced the timing difference below.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to split every unreachable connector into still-connecting, unauthenticated, or absent, and route each one to a different response automatically
✦You will be able to prevent the failure mode where an unattended run reports a capability as missing and quietly accomplishes nothing
✦You will be able to move capability checks from the start of a run to the moment of use, so your tasks stop depending on startup order
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Splitting the wait budget by state removes most of the waiting
Once you can classify, you only wait on connecting. Give the wait an explicit budget and poll interval, and return a verdict when the budget runs out.
// resolver.mjs (continued) — capability resolution with a budgetexport async function resolve(toolName, opts) { const { snapshotOf, waitBudgetMs = 20000, pollMs = 2000, sleep } = opts; const deadline = Date.now() + waitBudgetMs; let waitedMs = 0; for (;;) { const state = classify(toolName, await snapshotOf()); if (state === READY) return { state, waitedMs }; // The two states time cannot fix: return without waiting if (state === UNAUTHENTICATED || state === ABSENT) return { state, waitedMs }; // Would the next poll overrun the budget? Then stop here if (Date.now() + pollMs > deadline) { return { state: CONNECTING, waitedMs, timedOut: true }; } await sleep(pollMs); waitedMs += pollMs; }}
sleep arrives as a parameter so tests can swap in a virtual clock. Code that waits on real time does not get tested, and untested code breaks without telling anyone.
To see the effect, I built a simulation around twelve tools. Seven are usable from the start, one appears on the fourth poll, one stays mid-handshake to the end, and three are waiting on authorization. Budget: 20 seconds. Poll interval: 2 seconds.
Two implementations were compared. A ignores the three states and treats anything missing from the inventory as "might still arrive," waiting uniformly. B routes through the classifier above.
A no states : total wait 86000 ms / 8 ready
B 3 states : total wait 26000 ms / 8 ready
delta: 60000 ms ( 69.8 % less )
Both runs end with the same eight usable tools. The extra sixty seconds A spent produced nothing at all: three unauthenticated connectors, twenty seconds each, entirely discarded — 69.8% of the total wait, roughly seven-tenths of it, contributing nothing.
The numbers come from a virtual-clock simulation, so a real environment will shift them with different poll intervals, budgets, and connector counts. The structure holds regardless: the more unauthenticated connectors you have, the longer the undifferentiated implementation waits, linearly — each additional unauthenticated connector adds the full 20-second budget, and every one of those seconds is waste.
If you have no basis for a starting value, I would recommend a 15–20 second budget with a 2-second poll interval; that is what I run. Stretching the budget to 60 seconds recovered exactly one slow connector, which does not pay for itself in a scheduled task with a hard runtime ceiling. Tightening it to 5 seconds, on the other hand, drops the connector that needed four polls — six seconds — to come online. That six-second figure is what set the floor.
Move the capability check from the start of the run to the moment of use
Three states will not help if you check in the wrong place. The common shape is to enumerate tools once at the top of the run and reuse that answer everywhere.
// ❌ Frozen at the entrance — slow connectors are lost permanentlyconst available = await listTools();const canUseDrive = available.includes("mcp__google-drive__search");// ...ten minutes of other work happens here...if (canUseDrive) { /* by now it is almost certainly usable */ }
In many runtimes, connectors continue coming online after the run begins. The opening snapshot is a photograph of one instant. You are making a decision that matters ten minutes from now using a picture taken ten minutes ago.
The fix is unglamorous: defer the decision until just before you need it.
// ✅ Resolve at the point of use, with a short-lived cacheconst cache = new Map();async function capability(toolName, ttlMs = 60000) { const hit = cache.get(toolName); if (hit && Date.now() - hit.at < ttlMs) return hit.value; const value = await resolve(toolName, { snapshotOf, sleep: realSleep }); // "ready" and "absent" are stable for a while. // "connecting" can flip at any moment, so give it a short life const at = value.state === CONNECTING ? Date.now() - ttlMs + 5000 : Date.now(); cache.set(toolName, { value, at }); return value;}
Varying the cache lifetime by state is the practical heart of it. ready and absent hold steady for tens of seconds; connecting may become ready on the very next tick. Give them one shared TTL and you will keep ignoring a connector that already finished starting up.
Unauthenticated will not heal, and that asymmetry belongs to unattended runs
While you are working interactively, an expired grant is a non-event. A prompt appears, you approve in the browser, you are back in under a minute.
In an unattended run, that path does not exist at all. The handshake presumes a human at a browser, so the task cannot even begin it. Miss this and you will design the connector as a transient fault worth retrying — and each retry stretches the run without changing the outcome.
So the accurate framing is that unauthenticated is not a failure but a handoff. Three rules have become my defaults:
On detection, abandon only the steps that depend on that connector — never the whole run
Let independent steps run to completion and produce their output
State plainly, in the final output, which connector was in which state and what it blocked
Skip the third and whoever picks this up ends up digging through logs anyway. Treating the output of an unattended run as a handoff note for the next human makes the content obvious. The same reasoning appears in When Claude Declines a Request on Safety Grounds, What Should an Unattended Pipeline Return? — only the reason for the refusal differs, safety judgment there, missing authorization here.
Fall back, halt, or hand it to a person
With three states in hand, decide each step's default behavior up front. Improvising this per step is how implementations drift.
// Declare steps: required or not, and whether a fallback existsconst steps = [ { id: "fetch-metrics", tool: "mcp__analytics__query", required: true, fallback: null }, { id: "post-summary", tool: "mcp__chat__send", required: false, fallback: "write-file" }, { id: "archive", tool: "mcp__storage__put", required: false, fallback: "write-file" },];async function planStep(step) { const { state } = await capability(step.tool); if (state === READY) return { action: "run" }; if (state === UNAUTHENTICATED) { // Waiting changes nothing. Halt if required, divert if optional return step.required ? { action: "abort", reason: `${step.tool} needs authorization` } : { action: "fallback", to: step.fallback, reason: "not authorized; using fallback" }; } if (state === CONNECTING) { // Budget exhausted while still handshaking. A later run may succeed return step.required ? { action: "retry-next-run", reason: "still starting when budget ran out" } : { action: "skip", reason: "still starting; skipped this run" }; } // absent return step.required ? { action: "abort", reason: `${step.tool} is not connected` } : { action: "fallback", to: step.fallback, reason: "not connected; using fallback" };}
unauthenticated and absent share the abort action for required steps, yet carry different wording on purpose: the reader's next move differs. One means redo the authorization; the other means add the connection. Two ways of saying "it stopped" — but only one of them tells the reader exactly what to do next, and that difference is measured in recovery time.
Log which state failed, not that something failed
The biggest win from three states was not the shorter runtime. It was that the logs became readable.
// Always emit one record at the end with the per-state breakdownfunction summarize(decisions) { const byState = decisions.reduce((acc, d) => { acc[d.state] = (acc[d.state] || 0) + 1; return acc; }, {}); return { ts: new Date().toISOString(), ran: decisions.filter(d => d.action === "run").length, blocked_by_auth: decisions .filter(d => d.state === "unauthenticated") .map(d => serverOf(d.tool)), still_connecting: decisions .filter(d => d.state === "connecting") .map(d => serverOf(d.tool)), absent: decisions.filter(d => d.state === "absent").map(d => serverOf(d.tool)), byState, };}
Compare that with "No matching capability was found" and the next action picks itself. Names under blocked_by_auth mean re-running authorization gets you a working task tomorrow. Only still_connecting means extending the budget a little, or moving that step later in the order. Anything under absent means the configuration itself needs attention.
Two things the implementation got wrong before it got right
Two results contradicted what I expected.
First, heavier retries lowered the completion rate for some steps. Intuitively, more attempts should catch more opportunities. But retrying against an unauthenticated connector catches nothing while consuming runtime. Runtime is capped, so wasted waiting early pushes legitimate work out of the window. Adding retries reduced the total number of completed steps. Undifferentiated waiting is not merely slow — it steals time from steps that would have succeeded.
Second, my tests never reproduced the inventory growing mid-run. The fixture pinned the tool list, so the connecting → ready transition was never exercised. Adding a virtual clock and a case where the tool appears on the fourth poll immediately surfaced a bug in the cache lifetime: a connector that had just come online stayed invisible for the rest of the TTL.
Tests that wait on real time are slow, so they stop being written; because they are not written, the transition path goes unverified. A virtual clock breaks that chain — six seconds of waiting verified in milliseconds.
Open the log of an unattended task you are running right now and find the line that stands for "it was not available." If that line carries no connector name and no state, the first step is adding exactly that.
Resolvers, budgets, and caches can all come later. A state you never recorded, though, cannot be recovered after the fact. I arrived at this three-state split only because I first built the habit of writing down what had been missing, back when I still had no idea why.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.