Tutorials
Sofia Nieves11 min read4 views

Claude Agent SDK skills: "docs:*" throws, "pdf-*" does not

The skills option validates every name before the session starts, and its wildcard guard tests for exactly two endings. Eight of eleven wildcard shapes get through, become a Skill(name) rule that names no skill, and say nothing. Measured first-party on 0.3.272, with the bisect.

Updated on September 15, 2026

Flat dark diagram of a filter gate with three slots. One amber token is stopped against the solid part of the gate and marked with a cross, while three blue tokens pass through the slots and converge into a single blue token on the right.
Flat dark diagram of a filter gate with three slots. One amber token is stopped against the solid part of the gate and marked with a cross, while three blue tokens pass through the slots and converge into a single blue token on the right.
On this page

Quick answer (September 2026). In @anthropic-ai/claude-agent-sdk, the skills option validates every name you pass before the session starts, and its wildcard guard tests for exactly two shapes: a name ending in :* and a name ending in a space followed by *, plus the bare * on its own. Everything else with a star in it passes. skills: ["docs:*"] throws Invalid skill name "docs:*": wildcard-suffix names are not allowed; list each skill by its exact name. and skills: ["pdf-*"] starts the session with no error at all. Measured on 0.3.272: 8 of 11 wildcard shapes are accepted. The accepted ones are turned into an allowedTools entry of the form Skill(pdf-*), and by the SDK's own rule that entry names no skill. Two sibling routes to the same allowlist, AgentDefinition.skills and a hand-written allowedTools entry, run no validation whatsoever.

What this tutorial covers

You will reproduce three things on your own machine, none of which needs an API key or a running model:

  1. The full validation matrix for the skills option, including the six wildcard shapes it lets through.
  2. The desugaring step, by capturing the argument vector the SDK hands to the Claude Code process.
  3. The two sibling routes that reach the same allowlist without validation.

Then you get a detector you can paste into a test file.

Versions used. @anthropic-ai/claude-agent-sdk 0.3.272, published 14 September 2026, the latest of 298 published versions at the time of writing. Node 24.8.0. Every figure below was produced against that exact version.

understand what the skills option actually does

The skills option looks like a first-class feature with its own machinery. It is a sugar layer over allowedTools.

Anthropic's own documentation says so in passing, in Extend agents with skills:

Source, verbatim: "When you set skills, the SDK adds the Skill tool to allowedTools automatically."

The shipped code is more specific than that sentence. Inside the SDK's initialize() path, the option is rewritten into permission rules before the subprocess is spawned. Reduced to its shape:

javascript
// @anthropic-ai/claude-agent-sdk 0.3.272, sdk.mjs, inside initialize()
// Variable names restored from the minified bundle.
if (skills !== undefined) {
  const rules = skills === "all"
    ? ["Skill"]
    : skills.map((name) => `Skill(${validateSkillName(name)})`);
  const existing = new Set(allowedTools);
  allowedTools = [...allowedTools, ...rules.filter((r) => !existing.has(r))];
}

Three things follow from that, and all three matter later:

  • Each name becomes a permission rule Skill(name), appended to whatever allowedTools you already passed.
  • skills: "all" becomes the single bare entry Skill, which is exactly the form the SDK's own type definitions mark as deprecated when you write it by hand.
  • validateSkillName runs on this path and only on this path.

reproduce the validation matrix

Create a scratch directory and install the SDK:

bash
mkdir -p /tmp/skills-probe && cd /tmp/skills-probe
npm init -y
npm install @anthropic-ai/claude-agent-sdk
node -p "require('./node_modules/@anthropic-ai/claude-agent-sdk/package.json').version"
# 0.3.272

The version check uses a relative path on purpose. The package's exports map does not expose ./package.json, so the shorter bare-specifier form fails with ERR_PACKAGE_PATH_NOT_EXPORTED rather than printing a version. npm ls @anthropic-ai/claude-agent-sdk works too.

The validation runs before the SDK spawns anything, so you can trigger it with a deliberately invalid executable path. If validation rejects the name, you get the skill-name error. If validation accepts it, you get the missing-binary error instead. That difference is the entire test.

javascript
// probe.mjs
import { query } from "@anthropic-ai/claude-agent-sdk";

const cases = [
  ["Skill(pdf-processor)", "copied from an allowedTools rule"],
  ["pdf-*",                "hyphen-star wildcard"],
  ["docs:*",               "plugin-namespace wildcard"],
  ["*",                    "bare star"],
  ["/pdf-processor",       "slash-command form"],
  [" pdf-processor ",      "padded with whitespace"],
  ["",                     "empty string"],
  ["pdf,xlsx",             "two names in one entry"],
  ["pdf-processor",        "VALID control"],
];

for (const [name, note] of cases) {
  let verdict;
  try {
    const it = query({
      prompt: "hi",
      options: { skills: [name], pathToClaudeCodeExecutable: "/nonexistent/x" },
    });
    await it.next();
    verdict = "ACCEPTED";
  } catch (e) {
    verdict = e.message.startsWith("Claude Code native binary not found")
      ? "ACCEPTED (validation passed, failed later at spawn)"
      : "REJECTED: " + e.message;
  }
  console.log(`${JSON.stringify(name)}  (${note})\n  ${verdict}\n`);
}

Run it with node probe.mjs. The results, measured:

Scroll to see more

Entry passed to skillsVerdictMessage
Skill(pdf-processor)REJECTEDparentheses, commas, and control characters are not allowed in skill names
pdf-*ACCEPTEDnone
docs:*REJECTEDwildcard-suffix names are not allowed; list each skill by its exact name
*REJECTEDInvalid skill name '*': use skills: 'all' to enable every skill.
/pdf-processorREJECTEDskill names may not start with '/' ...
" pdf-processor "REJECTEDleading or trailing whitespace is not allowed ...
""REJECTEDSkill names must be non-empty strings.
pdf,xlsxREJECTEDparentheses, commas, and control characters are not allowed in skill names
pdf-processorACCEPTEDnone

Eight of nine behave exactly as the documentation describes. The second row is the one worth your attention.

find the edge of the wildcard guard

The guard is a two-term test. Written out from the shipped bundle, it is equivalent to:

javascript
if (name.endsWith(":*") || name.endsWith(" *")) {
  throw new Error(`Invalid skill name ${quote(name)}: wildcard-suffix names are not allowed; list each skill by its exact name.`);
}

A bare * is caught separately, one line earlier, with its own message pointing you at skills: "all".

So three shapes are rejected and everything else containing a star is not. Measured across eleven shapes on 0.3.272:

Scroll to see more

ShapeResult
pdf-*ACCEPTED
pdf*ACCEPTED
*-processorACCEPTED
*pdfACCEPTED
pdf.*ACCEPTED
pdf_*ACCEPTED
pdf/*ACCEPTED
pdf-?ACCEPTED
docs:*REJECTED
docs *REJECTED
*REJECTED

Eight of eleven pass. The three that are caught are the three the documentation names. The docs page lists the rejected forms as "A wildcard form such as a bare * or a :* suffix", which is an accurate description of the guard and not a description of wildcards in general. If you read that line as a promise that wildcards are rejected, pdf-* will surprise you.

The asymmetry is what makes this worth knowing. Both docs:* and pdf-* express the same intent. One of them stops your process with a clear, actionable message. The other one starts a session that looks healthy.

prove the desugaring

An accepted name does not vanish. It becomes a permission rule. You can watch that happen without a Claude Code install by pointing the SDK at a script that records its own arguments.

bash
cat > /tmp/skills-probe/fakecli.sh <<'SH'
#!/bin/sh
printf '%s\n' "$@" > /tmp/skills-probe/argv.txt
exit 0
SH
chmod +x /tmp/skills-probe/fakecli.sh
javascript
// desugar.mjs
import { query } from "@anthropic-ai/claude-agent-sdk";
import fs from "node:fs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function run(label, options) {
  try { fs.unlinkSync("/tmp/skills-probe/argv.txt"); } catch {}
  try {
    const it = query({
      prompt: "hi",
      options: { ...options, pathToClaudeCodeExecutable: "/tmp/skills-probe/fakecli.sh" },
    });
    await it.next();
  } catch {}
  await sleep(700);                       // the subprocess writes after next() resolves
  const argv = fs.readFileSync("/tmp/skills-probe/argv.txt", "utf8").split("\n").filter(Boolean);
  const i = argv.indexOf("--allowedTools");   // camelCase, not --allowed-tools
  console.log(label.padEnd(46), i === -1 ? "(flag absent)" : JSON.stringify(argv[i + 1]));
}

await run('skills: ["pdf-processor"]',            { skills: ["pdf-processor"] });
await run('skills: ["pdf-*"]',                    { skills: ["pdf-*"] });
await run('skills: "all"',                        { skills: "all" });
await run('allowedTools:["Read"] + skills:[..]',  { allowedTools: ["Read"], skills: ["pdf-processor"] });
await run('allowedTools: ["Skill(pdf-*)"] only',  { allowedTools: ["Skill(pdf-*)"] });
await run('neither option set',                   {});

Output, verbatim:

text
skills: ["pdf-processor"]                      "Skill(pdf-processor)"
skills: ["pdf-*"]                              "Skill(pdf-*)"
skills: "all"                                  "Skill"
allowedTools:["Read"] + skills:[..]            "Read,Skill(pdf-processor)"
allowedTools: ["Skill(pdf-*)"] only            "Skill(pdf-*)"
neither option set                             (flag absent)

Two rows deserve a second look.

Row two and row five are byte-identical. The rule the SDK builds from skills: ["pdf-*"] is the same rule you would get by writing allowedTools: ["Skill(pdf-*)"] by hand, which is the route the type definitions mark as deprecated. The validated path and the deprecated path converge on the same output.

Row three shows skills: "all" producing the bare Skill entry. The SDK writes the deprecated form for you, which is fine, because the deprecation is about you hand-rolling it rather than about the entry itself.

what the surviving rule matches

The SDK is clear about what an exact name is for. From the same documentation page:

Source, verbatim: "The list takes exact skill names only. If an entry can't work as an exact name, query() rejects the list before the session starts."

And, one paragraph later:

Source, verbatim: "To let Claude invoke every discovered skill, pass skills: "all" rather than a wildcard."

The rejection message for docs:* says the same thing from the other side: list each skill by its exact name. A star is not part of an exact name, so Skill(pdf-*) is a rule that names a skill called, literally, pdf-*. Unless you have a directory with that name, nothing matches it.

The symptom you get is not a startup error. It is a tool result later in the run. When Claude reaches for a skill that the session's list does not cover, the Skill tool refuses it with a message ending is not in this session's skills allowlist. You will be looking at a session that started cleanly, so the natural first suspicion is the skill file, the frontmatter or settingSources, and not the option you typed.

Boundary, stated plainly. Everything above through step 4 is measured first-party on 0.3.272. This step is not. Confirming what Skill(pdf-*) matches at runtime needs a real Claude Code install and a real skill directory, which this tutorial deliberately does not require. What is quoted here is Anthropic's own description of the rule format, in three separate places that agree with each other. Treat the match behaviour as well-sourced rather than as something reproduced on this page.

the two routes that are not validated at all

Options.skills is one of three ways a Skill(...) rule reaches a session. The other two run no name validation.

Route two: a hand-written allowedTools entry. Row five of the desugaring output is the proof. allowedTools: ["Skill(pdf-*)"] produces the rule with no check and no complaint. The validator is attached to the option, not to the rule format.

Route three: per-subagent skills. AgentDefinition carries its own skills array, documented in the type definitions as "Array of skill names to preload into the agent context". Pass the same four names that Options.skills rejects:

javascript
// agent-skills.mjs
import { query } from "@anthropic-ai/claude-agent-sdk";

const it = query({
  prompt: "hi",
  options: {
    agents: {
      reviewer: {
        description: "d",
        prompt: "p",
        skills: ["Skill(pdf-processor)", "pdf-*", "docs:*", "  padded  "],
      },
    },
    pathToClaudeCodeExecutable: "/tmp/skills-probe/fakecli2.sh",
  },
});
await it.next();

Nothing throws. Not even docs:*, which Options.skills rejects in the same process on the same version.

The names are not quietly dropped either. Subagent definitions travel to the Claude Code process over the control protocol rather than as a command-line flag, so capture stdin instead of argv:

bash
cat > /tmp/skills-probe/fakecli2.sh <<'SH'
#!/bin/sh
timeout 3 cat > /tmp/skills-probe/stdin.txt
exit 0
SH
chmod +x /tmp/skills-probe/fakecli2.sh

The initialize payload carries all four verbatim:

text
"reviewer":{"description":"d","prompt":"p","skills":["Skill(pdf-processor)","pdf-*","docs:*","  padded  "]}

Same process, same version, same four strings, two different answers depending on which field you put them in. If you are migrating a working configuration, that is the sharp edge: moving a name from Options.skills down into a subagent definition removes the only check that would have told you the name was wrong.

when the check landed

Do not infer direction from reading one version. Here is the bisect, taken by unpacking published tarballs and grepping for the validator's own error strings:

Scroll to see more

VersionPublishedvalidateSkillName presentWildcard guard
0.3.2002026-06nono
0.3.21014 Jul 2026nono
0.3.21924 Jul 2026nono
0.3.22024 Jul 2026nono
0.3.2213 Aug 2026yesyes
0.3.25231 Aug 2026yesyes
0.3.27214 Sep 2026yesyes

You can reproduce a row in two commands:

bash
npm pack @anthropic-ai/claude-agent-sdk@0.3.220
tar -xzOf anthropic-ai-claude-agent-sdk-0.3.220.tgz package/sdk.mjs \
  | grep -c "wildcard-suffix names are not allowed"
# 0

Anthropic's documentation states the same boundary independently: "Before TypeScript Agent SDK 0.3.221, the SDK didn't run this check." The Python SDK's equivalent boundary is its own 0.2.129.

So this is not a regression. The guard expression is byte-identical from 0.3.221 through 0.3.272, six weeks and fifty-one releases. It has never been wider and never been narrower. The gap has been there since the option shipped, which is a different and milder thing than something that used to work and broke. It also means no version pin helps you: there is no earlier release where pdf-* was caught.

a detector you can run

Validation only covers one of the three routes, so check the other two yourself. This runs offline, spawns nothing and needs no API key.

javascript
// check-skill-names.mjs
const STAR = "*";

function problems(name, where) {
  const out = [];
  if (typeof name !== "string" || name.trim() === "") {
    out.push(`${where}: empty or non-string entry`);
    return out;
  }
  if (name.includes(STAR) || name.includes("?")) {
    out.push(`${where}: ${JSON.stringify(name)} contains a wildcard character. Skill rules take exact names; list each skill.`);
  }
  if (/[(),]/.test(name)) {
    out.push(`${where}: ${JSON.stringify(name)} contains a parenthesis or comma. Pass "pdf-processor", not "Skill(pdf-processor)".`);
  }
  if (name !== name.trim()) {
    out.push(`${where}: ${JSON.stringify(name)} has surrounding whitespace.`);
  }
  if (name.startsWith("/")) {
    out.push(`${where}: ${JSON.stringify(name)} is the slash-command form. Use the canonical name.`);
  }
  return out;
}

export function checkSkillNames(options) {
  const found = [];

  if (Array.isArray(options.skills)) {
    options.skills.forEach((n, i) => found.push(...problems(n, `skills[${i}]`)));
  }

  for (const entry of options.allowedTools ?? []) {
    const m = /^Skill\((.*)\)$/.exec(entry);
    if (m) found.push(...problems(m[1], `allowedTools "${entry}"`));
  }

  for (const [agent, def] of Object.entries(options.agents ?? {})) {
    (def.skills ?? []).forEach((n, i) => found.push(...problems(n, `agents.${agent}.skills[${i}]`)));
  }

  return found;
}

Wire it into whatever builds your options object:

javascript
import { checkSkillNames } from "./check-skill-names.mjs";

const options = {
  skills: ["pdf-*"],
  allowedTools: ["Read", "Skill(docs:*)"],
  agents: { reviewer: { description: "d", prompt: "p", skills: ["  padded  "] } },
};

const found = checkSkillNames(options);
if (found.length) {
  console.error("Skill name problems:");
  for (const f of found) console.error("  " + f);
  process.exit(1);
}

Output on that input:

text
Skill name problems:
  skills[0]: "pdf-*" contains a wildcard character. Skill rules take exact names; list each skill.
  allowedTools "Skill(docs:*)": "docs:*" contains a wildcard character. Skill rules take exact names; list each skill.
  agents.reviewer.skills[0]: "  padded  " has surrounding whitespace.

Note that the SDK itself would reject only the first of those three, and only because it happens to sit in skills. Move it to allowedTools and the SDK goes quiet.

what to write instead

There are exactly two supported ways to express "more than one skill".

Enumerate them. This is what the option is designed for, and it is what the error message asks for.

javascript
const options = {
  settingSources: ["user", "project"],
  skills: ["pdf-extract", "pdf-merge", "pdf-sign"],
  allowedTools: ["Read", "Grep", "Glob"],
};

Or take all of them. If your intent really was "every skill I have", say that. It is a distinct value and it is the one the bare-star error message points you at.

javascript
const options = {
  settingSources: ["user", "project"],
  skills: "all",
};

There is a third state that is easy to reach by accident: omitting skills entirely is not the same as disabling skills. The type definitions are explicit that an omitted option means no SDK auto-configuration and that the CLI's own defaults still apply, so this is "not skills off". If you want none, pass an empty array.

This is also the place to note what the option is not. Anthropic describes it as "a context filter, not a sandbox: unlisted skills are hidden from the model's listing and rejected by the Skill tool, but their files remain on disk and are reachable via Read/Bash." A skill name you leave off the list is hidden, not protected. If the content is sensitive, the list is the wrong control.

For comparison, the OpenAI Agents SDK takes the same position on its own tool filtering: allowed_tool_names and blocked_tool_names are lists of exact names with no wildcard syntax, and anything more expressive goes through a callable filter instead. Exact-name allowlists are the norm across the category, not an Anthropic quirk. The Model Context Protocol specification likewise defines tools by exact name, with discovery as a separate step from invocation.

Scope and limits

  • Everything in steps 1 through 4, 6 and 7 was reproduced on @anthropic-ai/claude-agent-sdk 0.3.272 with Node 24.8.0, using only the SDK and a shell script standing in for the Claude Code binary.
  • Step 5 is sourced from Anthropic's documentation and the SDK's own error text, not reproduced. A live session with a populated skills directory would be needed to observe the non-match directly.
  • This covers the TypeScript SDK. The Python package claude_agent_sdk has its own validator and its own version boundary at 0.2.129, and the shapes it accepts were not measured here.
  • The finding is about name validation coverage, not about a broken feature. Enumerated names work. skills: "all" works. The gap is the set of malformed entries the SDK lets through without comment.

If you are working through the SDK's configuration surface, the Unknown hook type walkthrough covers a neighbouring case where a malformed settings entry is dropped without a message, and Claude Agent SDK subagents covers the agents option whose skills array is described in step 6.

Sofia Nieves

Written by

Sofia Nieves

Sofia works on agent evaluation and reliability. She writes about measuring LLM systems before and after they reach production.

Frequently asked questions

Why does skills: ["docs:*"] throw in the Claude Agent SDK but skills: ["pdf-*"] not?

Because the SDK's wildcard guard tests for exactly two endings: a name ending in ':*' and a name ending in a space followed by a star. A bare star on its own is caught separately with its own message. Every other shape containing a star passes. Measured on @anthropic-ai/claude-agent-sdk 0.3.272, eight of eleven wildcard shapes are accepted, including pdf-*, pdf*, *-processor and pdf_*. The rejected ones are docs:*, 'docs *' and the bare star.

What does the skills option actually do to allowedTools?

It desugars into permission rules. Each name you pass becomes an entry of the form Skill(name) appended to allowedTools, and skills: 'all' becomes the single bare entry Skill. You can watch this by pointing pathToClaudeCodeExecutable at a script that prints its own arguments: skills: ['pdf-processor'] produces --allowedTools "Skill(pdf-processor)", and allowedTools: ['Read'] plus skills: ['pdf-processor'] produces "Read,Skill(pdf-processor)".

Which version of the Claude Agent SDK added skill name validation?

TypeScript Agent SDK 0.3.221, published 3 August 2026. Version 0.3.220, published 24 July 2026, does not contain the validator at all. Anthropic's documentation states the same boundary. The guard expression is byte-identical from 0.3.221 through 0.3.272, so this is not a regression and no version pin avoids it. The Python SDK has its own boundary at 0.2.129.

Are per-subagent skills validated the same way?

No. AgentDefinition carries its own skills array, and passing the same names that Options.skills rejects throws nothing at all. The names are not dropped either: they travel verbatim to the Claude Code process in the initialize payload over the control protocol. A hand-written allowedTools entry of the form Skill(name) is likewise unvalidated. Validation is attached to the Options.skills field, not to the rule format.

How do I enable several skills at once without a wildcard?

List them by exact name, for example skills: ['pdf-extract', 'pdf-merge', 'pdf-sign']. That is what the rejection message asks for. If you genuinely want every discovered skill, pass skills: 'all', which is a distinct value and the one the bare-star error points you at. Anthropic's documentation says the list takes exact skill names only.

Does leaving the skills option out disable skills?

No. The SDK type definitions are explicit that an omitted option means no SDK auto-configuration and that the CLI's own defaults still apply, so omitting it is not skills off. Pass an empty array if you want none. Note also that the option is a context filter rather than a sandbox: unlisted skills are hidden from the model's listing and rejected by the Skill tool, but their files stay on disk and remain reachable through Read and Bash.