CLAUDE LABJP
FABLE — Claude Fable 5 subscription access is settled: Max and Team Premium keep it included up to 50% of weekly limits, while Pro and Team Standard move to metered creditsSKILLS — Skills sharing a name with terminal built-ins like /help or /feedback were un-invocable in non-interactive sessions. That is now fixedMCPINIT — SDK MCP servers registered through an initialize control request start connecting right away instead of waiting for the next turnRESYNC — Two fixes land together: plugin-provided MCP servers being torn down during mid-session re-syncs, and a race where two processes refreshed the same OAuth token and forced re-authSTREAM — The --forward-subagent-text flag and CLAUDE_CODE_FORWARD_SUBAGENT_TEXT env var include subagent text and thinking in stream-json outputPRICE — Sonnet 5 promotional pricing of $2/$10 per Mtok runs through August 31, with standard $3/$15 pricing resuming September 1FABLE — Claude Fable 5 subscription access is settled: Max and Team Premium keep it included up to 50% of weekly limits, while Pro and Team Standard move to metered creditsSKILLS — Skills sharing a name with terminal built-ins like /help or /feedback were un-invocable in non-interactive sessions. That is now fixedMCPINIT — SDK MCP servers registered through an initialize control request start connecting right away instead of waiting for the next turnRESYNC — Two fixes land together: plugin-provided MCP servers being torn down during mid-session re-syncs, and a race where two processes refreshed the same OAuth token and forced re-authSTREAM — The --forward-subagent-text flag and CLAUDE_CODE_FORWARD_SUBAGENT_TEXT env var include subagent text and thinking in stream-json outputPRICE — Sonnet 5 promotional pricing of $2/$10 per Mtok runs through August 31, with standard $3/$15 pricing resuming September 1
Articles/Claude Code
Claude Code/2026-08-06Advanced

The Skill Call That Returned Nothing — Pinning Down Name Resolution Before an Unattended Run

A skill whose name collides with a built-in terminal command may never resolve in a non-interactive session. Here is a preflight that builds the effective-name table before launch, plus the measured run where the checker itself silently skipped two entries.

Claude Code211Skill DesignUnattended Automation3Preflight2Namespaces

Premium Article

I opened the logs from an overnight scheduled run and found almost nothing in them.

No failure. No stack trace. No timeout record. The step I had wired up simply never executed.

Before I found the real cause, I spent three hours suspecting permission settings and network allowlists. Both were innocent. What actually happened was far more mundane: a skill I was distributing through a plugin shared its name with a built-in terminal command.

In an interactive session, typing that name gets you the built-in. It looks like everything works. In a non-interactive session, the same call resolves to nobody at all, and falls on the floor without a sound.

That particular defect was fixed in the August 6, 2026 update. But what got fixed is a symptom, not the shape of the problem. Call names arrive from several sources, and the effective name is decided by a resolution order. If you name a skill without knowing that order, you will break the same way again — just as quietly.

So I wrote a tool that builds the effective-name table before launch. Then I ran it, and found something the tool itself had been missing.

Call names are a resolution order, not a list

Skills come from more than one place. The project directory. Your home directory. Plugins you have installed. In an organization, add whatever an administrator distributes on top of that.

It is tempting to think of this as "the list of skills I have available." It is not a list. It is a single namespace that several independent sources write into.

SourceLocationWho changes itWill you notice a change?
Built-in commandsThe terminal itselfThe vendorOnly if you read the changelog
ProjectInside the repositoryYou and your teamYes, it shows in the diff
UserUnder your home directoryYouNot if it is outside version control
PluginInstalled pluginsWhoever publishes itAlmost never

The bottom two rows are where the trouble lives. Skills under your home directory never appear in a repository diff. Skills provided by a plugin enter your namespace the moment the publisher adds a new name, without asking you.

Which means whether your own skill still answers to its name tomorrow is not entirely your decision.

Built-in commands sit on top of all of this. They win, and they change fastest. In the first week of August alone, new manually invocable skill entry points were added. A name that was free yesterday can be occupied today.

Why interactive and non-interactive behave differently

In an interactive session, whatever you type passes through the terminal's input layer first. If it matches a built-in command, resolution stops there. Since a human is doing the typing, a missing response registers immediately.

A non-interactive session has no such input layer. The call goes to the agent's own resolution path. When the name is reserved on the built-in side, the agent's skill table and the vendor's reserved words disagree, and the call is left hanging.

And in unattended execution, nobody is around to complain about a hanging call.

The tell I missed for three hours: a permission problem leaves a denial record. The complete absence of any record was itself the evidence that permissions were not the cause.

Building the effective-name table before launch

What I needed was a way to answer, on every run, a single question: if I call this name, which file actually executes?

I settled on three rules.

  1. Scan every source and collapse the results into one effective-name table.
  2. Treat a collision with a built-in command as a stop condition, not a warning.
  3. Treat any source that could not be scanned as a failure in its own right.

The third rule turned out to matter more than I expected.

Here is the implementation. It uses only Node.js standard modules — adding a dependency invites the failure where the package is simply absent in the unattended environment.

#!/usr/bin/env node
// name-preflight.mjs — resolve skill call names before launch and block collisions
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
import { join, basename } from "node:path";
 
// Built-in terminal commands. Derive this from /help in your own environment
// and keep it under version control as a fixed list.
const BUILTIN = new Set([
  "help", "clear", "compact", "config", "cost", "doctor", "exit",
  "init", "login", "logout", "mcp", "memory", "model", "permissions",
  "release-notes", "resume", "review", "rewind", "status", "vim",
]);
 
// Lower rank resolves first. Always overwrite this with what you observe
// in your own environment.
const SOURCES = [
  { id: "project", rank: 1, root: ".claude/skills" },
  { id: "user",    rank: 2, root: join(process.env.HOME ?? "", ".claude/skills") },
  { id: "plugin",  rank: 3, root: ".claude/plugins", nested: true },
];
 
const missingRoots = [];
 
function readName(dir) {
  const f = join(dir, "SKILL.md");
  if (!existsSync(f)) return null;
  const head = readFileSync(f, "utf8").split(/\n---\s*\n/)[0];
  const m = head.match(/^name:\s*(.+)$/m);
  return (m ? m[1] : basename(dir)).trim().replace(/^["']|["']$/g, "");
}
 
function scanFlat(root, source, owner) {
  if (!existsSync(root)) { missingRoots.push(`${source}:${root}`); return []; }
  return readdirSync(root)
    .map((e) => join(root, e))
    .filter((p) => statSync(p).isDirectory())
    .map((p) => ({ name: readName(p), source, owner, path: p }))
    .filter((r) => r.name);
}
 
function collect() {
  const out = [];
  for (const s of SOURCES) {
    if (!s.nested) { out.push(...scanFlat(s.root, s.id, s.id)); continue; }
    if (!existsSync(s.root)) { missingRoots.push(`${s.id}:${s.root}`); continue; }
    for (const plug of readdirSync(s.root)) {
      out.push(...scanFlat(join(s.root, plug, "skills"), s.id, plug));
    }
  }
  return out;
}
 
function resolve(entries) {
  const rank = Object.fromEntries(SOURCES.map((s) => [s.id, s.rank]));
  const byName = new Map();
  for (const e of entries) {
    const list = byName.get(e.name) ?? [];
    list.push(e);
    byName.set(e.name, list);
  }
  const findings = [];
  for (const [name, list] of byName) {
    list.sort((a, b) => rank[a.source] - rank[b.source]);
    const winner = list[0];
    if (BUILTIN.has(name)) {
      findings.push({
        level: "block", name,
        reason: "collides with a built-in command; may never resolve in a non-interactive session",
        effective: "builtin", losers: list,
      });
      continue;
    }
    if (list.length > 1) {
      findings.push({
        level: "warn", name,
        reason: `${list.length} skills share this name; ${winner.source}(${winner.owner}) wins`,
        effective: `${winner.source}:${winner.owner}`, losers: list.slice(1),
      });
    }
  }
  return { total: entries.length, unique: byName.size, findings };
}
 
const t0 = process.hrtime.bigint();
const r = resolve(collect());
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
 
const blocks = r.findings.filter((f) => f.level === "block");
const warns  = r.findings.filter((f) => f.level === "warn");
for (const m of missingRoots) console.log(`[GAP] scan target does not exist: ${m}`);
console.log(`scanned=${r.total} unique=${r.unique} block=${blocks.length} warn=${warns.length} in ${ms.toFixed(1)}ms`);
for (const f of [...blocks, ...warns]) {
  console.log(`[${f.level.toUpperCase()}] /${f.name} -> ${f.effective}`);
  console.log(`        ${f.reason}`);
  for (const l of f.losers) console.log(`        shadowed: ${l.source}:${l.owner} (${l.path})`);
}
process.exit(blocks.length > 0 || missingRoots.length > 0 ? 1 : 0);

BUILTIN is a hardcoded constant on purpose. If you query the live list of built-in commands instead, a failed query hands you a comforting "zero collisions." A dull list in version control, reviewed by eye whenever the tool updates, is more trustworthy in an unattended context.

The same reasoning applies to rank. Guessing at the precedence order is not good enough. Create a deliberate collision in your own environment, observe which side answers, and overwrite the values. What the code contributes is that the order is fixed and written down — not that my particular ordering is correct for you.

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
A ~90-line Node.js preflight that blocks built-in name collisions before launch, with measured output from a 10-skill tree
How one unscanned source root changes the finding count from 4 to 5, and how to make missing roots fail loudly instead of silently
Why a directory-name search finds 1 match where the effective-name count is 2, plus a measured 18ms scan across 500 skills
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.

or
Unlock all articles with Membership →
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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Claude Code2026-07-18
I Believed Plan Mode Only Read — Replacing That Belief With Machinery
Claude Code 2.1.212 fixed a bug where plan mode ran file-modifying Bash commands without the permission prompt or the SDK canUseTool callback. Here is what could happen while that assumption was broken, how to verify your own setup, and how to stop leaning on a mode name for safety.
Claude Code2026-06-28
When You Fan Out Streaming Sessions, Your Laptop's CPU Gives Out First — An Adaptive Throttle That Caps Concurrency by Measured Load
Even with lighter streaming, fanning out many sessions on one machine saturates the host CPU before anything else. Here is why a fixed semaphore fails, plus a working adaptive gate that raises and lowers concurrency from measured CPU.
Claude Code2026-08-05
Passing the Request, Not the Secret — Where Sandbox Credential Masking Works and Where Substitution Breaks
Claude Code's sandbox credential masking lets processes read sentinel values while a proxy swaps in the real secret at send time. I rebuilt the mechanism as a minimal proxy and measured exactly which auth schemes survive the swap — and which break.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →