Compound Labs
Get the newsletter
THE STANDUPAnthropic fixes Claude 5.1 error spikesERROR SPIKES, FIX DEPLOYEDCOMPOUND MASTODONSkillWorks records inactive files beside their roster status22 INACTIVE FILES STILL LOADCTXWINDOWContext Window tracks AI lab changes and filters their evidenceFILTERS KEEP EVIDENCE TOGETHERAGENTWIREmicrosoft/agent-governance-toolkit - AI Agent Governance ToolkitPOLICY ENFORCEMENT FOR AI AGENTSCOMPOUND FACEBOOKCompound Portfolio restores its sweep after a missing module stops desk dataSWEEP RESTORED, DESK DATA WRITTENBLOCKDEXBlockDex shows no destination for captured rows with rejected slash names31 ROWS, NO DESTINATIONCONTEXT WINDOWOpenAI adds 1024p image pricing: $0.50 and $0.251024P IMAGE, $0.50 AND $0.25COMPOUND BLUESKYCompound Labs renamed job labels so job-run finds breaker stateBREAKER STATE, NOW FINDABLECTXWINDOWContext Window tracks document changes but failed mobileMOBILE WORKFLOW, ONE COLUMNAGENTWIREMineDojo Voyager: Open-Ended Embodied AgentOPEN-ENDED EMBODIED AGENTFOUNDER BLUESKYdeploy.sh now fails false Vercel production landingsFALSE LANDINGS NOW FAILCOMPOUNDlast known fixes redirect tracking after migrationSTUDIO SOURCES NOW SAY COMPOUNDCONTEXT WINDOWOpenAI adds sora-2-pro pricing720P VIDEO, $0.15, $0.30FOUNDER LINKEDINdeploy.sh now stops failed promotes from marking production landedFAILED PROMOTES NO LONGER LANDUSINGITUPUsing It Up records a receipt fixing a wobbling wooden tableONE RECEIPT, TABLE STAYED LEVELCOMPOUNDpnpm v12.4.2POSIX BIN SHIMS REPLACEDFOUNDER PEERLISTCompound Labs' deploy runner rejects failed promotesFAILED PROMOTES NO LONGER LANDTRUSTDESKTrustDesk fixes host migration redirects while keeping /api/ outOLD HOST, 308; API STAYS LIVE
Independent product R&D labFounded and run by Isaiah Kim, @kyisaiah47Newest commit Sep 16, 2026, AgentwireNewest writing Sep 16, 2026Site changelog Sep 16, 2026
Compound AI OpsCOMPOUND MASTODON15 September 2026

SkillWorks

22 INACTIVE FILES STILL LOAD

SkillWorks records inactive files beside their roster status

I shipped a directory page that told visitors there were zero listings while rendering a grid of 48 of them. Fixed it. Then, about nine hours later, shipped a different bug with the exact same symptom, in the exact same function, for a completely unrelated reason.

The product is SkillWorks, a scored directory of Claude Code skills, subagents, plugins and marketplace repos I'm building under Kynth. It reads from Supabase over PostgREST, and it needs a lot of row counts: total listings, counts per kind, how many are broken, how many have tracked installs. Counting rows without pulling them is the one thing PostgREST makes genuinely cheap, ask for Prefer: count=exact with Range: 0-0 and the total comes back in the Content-Range response header. No rows in the body at all.

That header is where both bugs live.

The first zero: the header didn't survive the cache

Original version, in src/lib/db.ts:

async function countRows(table: string, query: string, col = 'id'): Promise<number> {
  const res = await fetch(`${URL}/rest/v1/${table}?${query}&select=${col}&limit=1`, {
    headers: { apikey: KEY, Authorization: `Bearer ${KEY}`, Prefer: 'count=exact', Range: '0-0' },
    next: { revalidate: REVALIDATE },
  });
  const range = res.headers.get('content-range');
  return range ? Number(range.split('/')[1]) || 0 : 0;
}

Next's fetch cache stores the response body. On a cache hit it hands you back a Response assembled with a synthetic header set, and content-range is not in it. So every cache hit read null and fell through to 0.

What kept this hidden for a while is that statically prerendered routes were fine. Their one fetch happens at build time and is always a miss, so the real header is right there. Only routes rendering at runtime hit the cache, and those were the ones shipping "0 indexed" next to a full grid. /marketplaces read "None of the 0 marketplaces".

The fix is to stop caching a thing whose value lives outside the cached payload. Make the fetch cache: 'no-store' and wrap the whole function in unstable_cache, which memoises the return value instead. Still one request an hour, but now what's stored is a parsed number, which survives being stored.

unstable_cache stored my outage for a full hour, architecture
unstable_cache stored my outage for a full hour, architecture

The second zero: a failed read stored as an answer

That was August 1st, early afternoon. That evening Supabase had an outage, and I watched /skills render "Search all 0 listings" above cards that looked completely normal.

The cards were fine because they come from Next's fetch cache and it still had them. The counts came through the new uncached path, hit a dead index, and returned 0, which is the last line of the function above, doing exactly what it was written to do. And unstable_cache dutifully stored that 0 for the full REVALIDATE window, which is 3600 seconds.

That's the part that actually bothered me. The outage was maybe twenty minutes. The wrong number would have outlived it by forty. The database could be answering perfectly and the site would still be advertising zero listings, with no error, no degraded state, nothing to look at. A directory that confidently reports emptiness is worse off than one that's visibly broken, because nobody goes looking.

The || 0 was defensive code from the first bug that quietly became the second one. Returning a fallback and memoising a fallback are different operations, and one function was doing both.

Throw inside the cache, degrade outside it

const countRows = async (table: string, query: string, col = 'id'): Promise<number> => {
  try {
    return await unstable_cache(
      async () => {
        const res = await fetch(/* … */, { cache: 'no-store' });
        const range = res.headers.get('content-range');
        // THROW rather than return 0 when the index cannot be reached.
        // `unstable_cache` stores whatever the function RETURNS, for a full hour.
        if (!res.ok || !range) throw new Error(`count ${table}: ${res.status}`);
        const total = Number(range.split('/')[1]);
        if (!Number.isFinite(total)) throw new Error(`count ${table}: unparseable content-range`);
        return total;
      },
      ['count', table, query, col],
      { revalidate: REVALIDATE },
    )();
  } catch {
    // 0 is still what the caller gets — a directory page that 500s over a stat line is worse
    // than one that under-reports for a few seconds. The difference is this 0 is NOT cached.
    return 0;
  }
};

The caller still gets 0. A browse page shouldn't throw a 500 because a stat line in a search placeholder couldn't be computed. But the fallback now lives outside the memo, so nothing gets written, the next request re-reads, and the wrong number lasts exactly as long as the outage does and not one second longer.

The same weekend, in a different product, I hit the same shape from the other direction. ListRun has pages where the row is the page, a directory detail, a run, a paid receipt, and they all called a helper that returned null on any failed read, then called notFound(). Which means a slow database told a buyer their run does not exist, and on a cached render it baked that 404 in. The fix there was apiRequired in src/lib/site.ts: return null only on an actual 404 from the route, throw on anything else, so notFound() is always a statement about the row.

Both are the same mistake. A read that fails and a read that legitimately returns nothing produce the same value, and then something downstream, a cache, a router, treats that value as established fact and keeps it around. The failure gets laundered into data on the way out.

I've stopped trusting any fallback that sits inside a memo boundary. If a function can be wrong and can be stored, those two facts need to be separated by a throw.

What each account said

https://thecompound.tech/ Ops log: SkillWorks indexes Claude Code skills, subagents, plugins, and marketplace repositories, and 22 dormant or dead instruction files still load into every session. The roster marks those files inactive, but the loader still reads each full description. The roster check now records the status beside each file, so inactive instructions remain visible instead of looking current. #AIAgents
Mastodon
A failed read must throw, not return 0, how Next.js ISR bakes your fallback into the cache I run a directory site: ~3,500 rows in Postgres, read over PostgREST, rendered by Next.js with `revalidate` on every route. The headline number in the shell says "Search all 3,516 listings." For about a day it said "Search all 0 listings", next to a grid full of listings. The database was fine by then. Three separate bugs conspired, and all three are the same mistake wearing different clothes: **a read that degrades politely on failure, sitting underneath a cache that stores the polite answer.** ## Bug 1: the cache dropped the header the count lived in PostgREST returns an exact row count in the `Content-Range` response header if you ask for it, which is far cheaper than selecting rows and counting them: ```ts const res = await fetch(`${URL}/rest/v1/${table}?${query}&select=id&limit=1`, { headers: { apikey: KEY, Prefer: 'count=exact', Range: '0-0' }, next: { revalidate: 3600 }, // ← the bug }); const total = Number(res.headers.get('content-range').split('/')[1]); ``` Next's fetch cache stores the response **body** and replays it behind a synthetic set of headers. `content-range` is not one of them. So the first call worked, and every cache hit after it read `null` and produced `0`. Statically prerendered pages looked perfect, their single fetch happened at build time and was always a miss. Every dynamic route showed zero. If you're caching a fetch for anything that isn't in the body, cache the *parsed value* instead: mark the fetch `cache: 'no-store'` and wrap the function in `unstable_cache`. Cache things that survive being cached. ## Bug 2: the zero got memoised for an hour With the count now memoised, the failure path mattered. The read returned `0` when the response wasn't OK, sensible-looking, and `unstable_cache` dutifully stored that `0` for the full hour. So the wrong number outlived the outage. The index came back; the site kept saying zero, and looked completely healthy while doing it. For a directory, "0 listings" isn't a degraded state, it's a false claim about the world. Fix: throw inside the cached function. `unstable_cache` stores nothing on a throw, so the next request re-reads and the site heals the instant the database answers. ## Bug 3: the page containing the zero was also cached Here's the one I got wrong twice. The outer `catch` still returned `0`, on what felt like solid reasoning: *this* zero isn't memoised, so it lasts exactly as long as the outage. It missed the other cache. Every route declares `revalidate`. A background revalidation that renders "Search all 0 listings" writes that sentence into the ISR page cache and serves it for an hour on the landing and a day everywhere else. Not caching the zero doesn't help when the **page containing the zero** is the cached artifact. The worst instance was `/opengraph-image`, which drew "0 skills · 0 subagents · 0 plugins" onto the share card and pinned it to every link preview for a day. A share card is the one surface where a wrong number gets screenshotted and outlives your cache entirely. So it rethrows. Throwing is what makes Next keep the last good copy. ## The rule that fell out Not every read should fail loudly. Two here must degrade: the footer's freshness line, which appears on ~3,500 routes and is one sentence of fine print, and the build-time list behind `generateStaticParams`, which only decides what gets prerendered. Those go through a separate helper with `AbortSignal.timeout(5000)`, a plain `fetch` has no timeout, and a `try/catch` cannot catch a hang. The footer says "rebuild pending," which is honest, and the build finishes. Everything else fails. The test is simple: **if the page's whole reason to exist is that data, an empty render is a lie, and a lie is what gets cached.** The same family of bug shows up without any cache involved. PostgREST caps a response at 1,000 rows no matter what `limit` you send. A category count computed in JavaScript over `limit=20000` therefore summed the first 1,000 of 3,369 rows and published "1,000 listings", a suspiciously round number that was precisely the cap. The sitemap did it too: 1,070 URLs emitted against 3,516 real ones, silently dropping the entire long tail. Both now read a Postgres view that does the aggregation server-side. Truncation, a missing header, a caught exception. Every one of them returns a number rather than an error, and a number is believed. This is how we built SkillWorks, a scored index of Claude Code skills and subagents: https://skillworks.kynth.studio
DEV
Naming a subagent with a colon in it used to work fine. Claude Code now refuses to load that file, and the only place it tells you is a debug log nobody opens. SkillWorks scores every Claude Code skill, subagent and plugin on whether it actually loads. We moved those onto the broken list. #dev
Bluesky
Publish a subagent with no description line and nobody's Claude Code will ever hand it a task. Your file sits in the repository looking correct, it parses, it installs, and the one sentence the agent reads to decide when to delegate is absent, so there is nothing to route on. That failure is silent by construction. A run that never selects a subagent does not error. It does the work inline, finishes, and reports that it finished. The self-report is accurate about the outcome and says nothing about the file you spent an afternoon writing. ## What the census found On 2026-08-14 I read every Claude Code skill, subagent, plugin and marketplace repository that could be found on GitHub and skills, from source, and checked one thing: whether an agent runtime could register the file at all. Not whether it is good, not whether it is popular. 445,348 artefacts, of which 43,199 fail, 9.7 percent. The aggregate hides the finding. Subagents fail at 21.9 percent, skills at 7.9 percent, and the gap is 2.8 times. | Kind | Read from source | Failing | Rate | `name` required | `description` required | |---|---|---|---|---|---| | Subagent | 71,430 | 15,669 | 21.9% | Yes | Yes | | Skill | 347,382 | 27,398 | 7.9% | No, falls back to the directory name | Recommended | | Plugin | 20,212 | 33 | 0.16% | Not a frontmatter question, `plugin.json` must parse | Not specified | | Marketplace | 6,324 | 99 | 1.6% | Not a frontmatter question, the manifest must list at least one plugin | Not specified | The failure causes, counted across all four kinds: 38,183 files whose settings block does not parse, 4,119 subagent files with no name, 1,799 subagent files with no description. A file can carry more than one, so those do not sum to the total. ![Claude Code subagents fail 2.8x more often than skills, code](https://xowekqdsttxwbhfxvusa.supabase.co/storage/v1/object/public/crosspost/founder-devto/article-snippet.png) ## The same careless file is a working skill and a dead subagent The reason subagents fail three times harder is not that the people writing them are worse. It is a difference between two pages of documentation. Anthropic's subagent reference says "Only `name` and `description` are required" and marks both Required: Yes. The skills reference says "All fields are optional. Only description is recommended", marks `name` Required: No, and defaults it to the directory name. So one hurried block of settings at the top of a markdown file loads cleanly as a skill and dies as a subagent, and the author has no way to tell which of the two they wrote unless they read both pages. That asymmetry is the whole 2.8x. It is a specification difference, not a culture difference. ## A verdict that rests on someone else's sentence Here is the part that made me change how the index stores its own rules. The pass or fail decision is exactly this, from `src/data/census.json`: ```json { "always": ["broken-frontmatter", "broken-manifest", "empty-marketplace"], "subagent_only": ["no-name", "no-description"], "note": "works=false is exactly these flags and nothing else. Every other flag is a score deduction, not a load verdict." } ``` `no-name` and `no-description` sit under `subagent_only` for one reason: two sentences on claude say so. If Anthropic revises either page, 15,669 subagent failures stop being failures and the headline number moves, without a line of my code changing. So both sentences are stored in the product's claim register with a probe, which is the literal substring that has to still be present on the page. `ops/qa/facts-gate.mjs --verify` refetches both references and fails when a probe stops matching. Two dates are kept apart on purpose: `asOf` is the day a person read the source, `lastVerified` is the day a machine last confirmed the probe is still there, and `--stamp` may only move the second one. An HTTP 200 is not somebody having read the rule. Both probes last matched on 2026-09-04. ## What I changed I stopped treating a measured number as the thing that decays and started treating the rule underneath it as the thing that decays. The figures on the census page are my own measurements, so the only thing that can make them wrong is a bug in the export. The definition of "works" belongs to somebody else's documentation, and that is now the part with a probe on it and a gate that fails when it moves. Toolproof publishes nine indexes that read AI agent tooling from source and record what they find, with the method for each one stated on the page. The census is free, CC-BY-4.0, and every figure above comes out of it. <caption>A subagent with no description line never gets picked, so the rule that decides that is now stored with a probe and rechecked.</caption> https://toolproof.kynth.studio/dv
DEV