Skip to content

feat(types): add top-level skills option to ClaudeAgentOptions#804

Open
jsham042 wants to merge 11 commits intoanthropics:mainfrom
jsham042:feat/top-level-skills-option
Open

feat(types): add top-level skills option to ClaudeAgentOptions#804
jsham042 wants to merge 11 commits intoanthropics:mainfrom
jsham042:feat/top-level-skills-option

Conversation

@jsham042
Copy link
Copy Markdown

@jsham042 jsham042 commented Apr 9, 2026

Summary

Adds skills: list[str] | Literal["all"] | None to ClaudeAgentOptions as the single place to enable skills for the main session, mirroring the existing AgentDefinition.skills field for subagents (#684).

Today, enabling skills requires two non-obvious steps in unrelated fields:

options = ClaudeAgentOptions(
    allowed_tools=["Skill"],              # easy to forget
    setting_sources=["user", "project"],
)

With this change:

ClaudeAgentOptions(skills="all")            # every discovered skill
ClaudeAgentOptions(skills=["pdf", "docx"])  # named subset only
ClaudeAgentOptions()                         # default: no SDK auto-config (CLI defaults apply)

Users no longer put "Skill" in allowed_tools. The old way still works unchanged.

Behavior

When skills is set (not None):

1. CLI flags (works on all CLI versions): the transport computes effective allowed_tools and setting_sources at command-build time:

  • "all" → appends bare Skill to --allowedTools
  • [name, ...] → appends Skill(name) for each entry
  • setting_sources is None → defaults to ["user", "project"]
  • The caller's options object is never mutated; existing allowed_tools are preserved; explicit setting_sources take precedence; duplicate entries are not re-added.

2. initialize control request (forward-compatible): when skills is a list, it is forwarded as {"skills": [...]} so a supporting CLI filters which skills are loaded into the prompt (not just permission-gated). "all" and None both omit the field — they are equivalent at the wire level. Older CLIs ignore unknown initialize fields, so this degrades to permission-layer gating only.

skills=None (default) means the SDK does no automatic configuration. The CLI's own defaults still apply — which currently means all setting sources are loaded — so this is not "skills off." To suppress every skill from the listing, use skills=[].

Scope: context filter, not sandbox

skills=[...] controls what the model sees and can invoke. Unlisted skills are hidden from the listing and rejected by the Skill tool, but their files remain on disk — a session with Read or Bash can still access .claude/skills/** directly. Bundled skills and installed-plugin skills are discovered regardless of setting_sources; the skills allowlist is the single mechanism that hides them from the model's listing. For filesystem-level isolation, point cwd at a directory with only the desired skills, or use permission deny rules. Do not store secrets in skill files.

Related issues

Test plan

  • tests/test_transport.py — 8 individual tests + 7-case test_skills_option_matrix parametrized table
  • tests/test_query.pyskills presence/absence in the initialize payload (list sent; "all"/None omitted)
  • 476 tests pass; ruff and mypy clean
  • 10/10 e2e behavior matrix with real API calls against a CLI built from #27911 — see comment below

Adds a `skills: list[str] | None` field to ClaudeAgentOptions that mirrors
the existing field on AgentDefinition. When set, the SDK automatically:

- Adds `Skill` (or `Skill(name)` patterns for specific names) to the
  `--allowedTools` CLI flag.
- Defaults `setting_sources` to `["user", "project"]` when not
  already configured, so installed SKILL.md files are discovered.

Previously, enabling skills required both "Skill" in allowed_tools and an
explicit setting_sources list — a footgun the SDK can easily remove.

The existing `allowed_tools` and `setting_sources` fields are unchanged
and still take precedence when the caller sets them explicitly. The options
object itself is never mutated.
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 9, 2026

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@b70e7e1). Learn more about missing BASE report.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #804   +/-   ##
=======================================
  Coverage        ?   84.40%           
=======================================
  Files           ?       14           
  Lines           ?     2648           
  Branches        ?        0           
=======================================
  Hits            ?     2235           
  Misses          ?      413           
  Partials        ?        0           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

jsham042 added 4 commits April 9, 2026 14:32
In addition to translating `skills` into `Skill(name)` allowedTools
entries, also forward the list on the SDK `initialize` control request.
A supporting CLI can use this to filter which skills are loaded into the
system prompt (not just permission-gated). Older CLIs ignore unknown
initialize fields, so this is forward-compatible.
Unlisted skills are hidden from the listing and blocked at the Skill
tool, but their files remain readable via Read/Bash. Document the
boundary and the alternatives (local plugin, deny rules) for users who
need hard isolation.
API design refinement: skills is now the one place to enable skills
(users should not put 'Skill' in allowed_tools directly).

  None       - skills off (default)
  'all'      - every discovered skill
  [name,...] - named subset only
  []         - degenerate subset; setting_sources still defaults but no
               Skill entries are added (natural list semantics)

Type widened to list[str] | Literal['all'] | None. Transport, query
init, docstring, and tests updated.
'all' and omitted both mean 'no filter' at the wire level, so only send
the field when it is an explicit list. Keeps the CLI control schema as a
plain string[] (which the zod-to-proto pipeline can represent) while the
'all' sentinel remains at the Python API surface for ergonomics.
@jhonfarrell
Copy link
Copy Markdown

My current feature requires this PR

IgorTavcar added a commit to IgorTavcar/claude-agent-sdk-python that referenced this pull request Apr 10, 2026
- anthropics#806: setting_sources=[] truthiness fix
- anthropics#803: betas=[]/plugins=[] truthiness fix
- anthropics#786: ThinkingBlock missing signature crash fix
- anthropics#790: suppress ProcessError when result already received
- anthropics#658: capture real stderr in ProcessError
- anthropics#791: suppress stale task notifications between turns
- anthropics#763: guard malformed CLAUDE_CODE_STREAM_CLOSE_TIMEOUT env var
- anthropics#805: delete_session() cascades subagent transcript dir
- anthropics#804: top-level skills option on ClaudeAgentOptions
- anthropics#691: PostCompact hook event type support

479 tests passing, mypy clean, ruff clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Single table-driven test documenting the (skills value) ->
(allowedTools, setting_sources, initialize-wire) mapping for all seven
permutations: default, old-manual, 'all', subset, subset+explicit
sources, subset+merge-tools, empty list. Serves as regression for the
documented behavior table.
setting_sources=None falls through to the CLI default (all sources
enabled), so the local-plugin isolation pattern leaked project/user
skills. Correct it to setting_sources=[].
Bundled and installed-plugin skills are discovered regardless of
setting_sources, and the transport's truthy check means
setting_sources=[] is not sent anyway, so the previous recipe did not
work. State the honest boundary: skills=[...] is the single mechanism
for hiding skills from the listing; filesystem access is a separate
concern (cwd or permission rules).
@jsham042
Copy link
Copy Markdown
Author

E2E matrix: 10/10 passed

Ran a 10-case behavior matrix with real API calls against a CLI built from this branch + the Python SDK from #804. Project skills alpha/beta/gamma on disk plus a local plugin providing delta.

=== 1. nothing set ===
    opts: {}
  [PASS] tracked=['alpha', 'beta', 'gamma'] builtins=34
         note: CLI default: all sources discovered, SDK adds nothing

=== 2. old manual way ===
    opts: {'allowed_tools': ['Skill', 'Read'], 'setting_sources': ['user', 'project']}
  [PASS] tracked=['alpha', 'beta', 'gamma'] builtins=34

=== 3. skills='all' auto-wired ===
    opts: {'skills': 'all', 'allowed_tools': ['Read', 'Bash']}
  [PASS] tracked=['alpha', 'beta', 'gamma'] builtins=34
         note: should match #2 without writing Skill or setting_sources

=== 4. subset only ===
    opts: {'skills': ['alpha', 'gamma'], 'allowed_tools': ['Read', 'Bash']}
  [PASS] tracked=['alpha', 'gamma'] builtins=0

=== 5. subset + setting_sources override ===
    opts: {'skills': ['alpha'], 'setting_sources': ['project']}
  [PASS] tracked=['alpha'] builtins=0

=== 6. anti-pattern: subset + bare Skill ===
    opts: {'skills': ['alpha'], 'allowed_tools': ['Skill', 'Read']}
  [PASS] tracked=['alpha'] builtins=0
         note: listing should still be filtered; bare Skill only widens invocation perms

=== 7. plugin skill via allowlist ===
    opts: {'plugins': [{'type': 'local', 'path': '.../plugins/isolated'}], 'skills': ['isolated:delta'], 'allowed_tools': ['Read']}
  [PASS] tracked=['delta'] builtins=0
         note: allowlist matches plugin-qualified names; same filter mechanism as case 4

=== 4b. empty list (degenerate) ===
    opts: {'skills': [], 'allowed_tools': ['Read']}
  [PASS] tracked=[] builtins=0

=== 4c. nonexistent skill name ===
    opts: {'skills': ['nonexistent'], 'allowed_tools': ['Read']}
  [PASS] tracked=[] builtins=0
         note: filter to a name that isn't on disk → nothing advertised

=== 8. subagent skills (AgentDefinition) ===
    opts: {'skills': 'all', 'agents': {'reporter': AgentDefinition(..., skills=['alpha', 'gamma'])}}
  [PASS] tracked=['alpha', 'beta', 'gamma'] builtins=34
         note: main session unfiltered; subagent filter not exercised by this prompt

==================================================
RESULT: 10/10 passed
Test harness
"""Manual matrix test for ClaudeAgentOptions.skills (PRs feat/sdk-skills-load-filter +
feat/top-level-skills-option).

Project skills on disk: alpha, beta, gamma  (./.claude/skills/)
Plugin skill on disk:   delta               (./plugins/isolated/)

Each case asserts which of {alpha, beta, gamma, delta} the model can see.
Built-in skills (commit, review, ...) are tracked but not asserted on, since
they are baked into the CLI binary and not gated by setting_sources.
"""

import asyncio
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from claude_agent_sdk import AgentDefinition, ClaudeAgentOptions, ResultMessage, query

CLI = str(
    Path.home()
    / "code/claude-cli-internal/build-ant-native/@anthropic-ai/claude-cli-native-darwin-arm64/cli"
)
REPO = Path(__file__).parent
PLUGIN = str(REPO / "plugins" / "isolated")
TRACKED = {"alpha", "beta", "gamma", "delta"}

PROMPT = (
    "List EVERY skill name available to you in your system prompt, one per line, "
    "lowercase, no commentary. If there are none, output exactly: NONE"
)


@dataclass
class Case:
    name: str
    opts: dict[str, Any]
    expect_present: set[str]
    expect_absent: set[str] = field(default_factory=set)
    note: str = ""


def hermetic_env() -> dict[str, str]:
    """Scrub env vars leaked from a parent Claude Code session.

    We do NOT override CLAUDE_CONFIG_DIR because the CLI needs the user's
    auth credentials from ~/.claude. This means user-level plugins/MCP and
    built-in skills still load; assertions therefore only check the tracked
    project/plugin skills (alpha, beta, gamma, delta).
    """
    return {
        "CLAUDE_CODE_SESSION_ID": "",
        "CLAUDE_CODE_SSE_PORT": "",
        "CLAUDE_CODE_MESSAGING_SOCKET": "",
        "CLAUDE_CODE_EXECPATH": "",
        "CLAUDECODE": "",
    }


def parse_skills(text: str | None) -> set[str]:
    if text is None:
        return set()
    found: set[str] = set()
    for line in text.splitlines():
        token = line.strip().strip("`*- ").lower()
        if not token or token == "none":
            continue
        # accept "plugin:name" or bare "name"
        m = re.fullmatch(r"[a-z0-9_:\-]+", token)
        if m:
            found.add(token.split(":")[-1])
    return found


async def run_case(case: Case, env: dict[str, str]) -> bool:
    opts = ClaudeAgentOptions(
        cwd=str(REPO),
        cli_path=CLI,
        max_turns=3,
        env=env,
        **case.opts,
    )
    result_text: str | None = None
    try:
        async with asyncio.timeout(120):
            async for m in query(prompt=PROMPT, options=opts):
                if isinstance(m, ResultMessage):
                    result_text = m.result
    except Exception as e:  # noqa: BLE001
        print(f"  ERROR: {type(e).__name__}: {e}")
        return False

    seen = parse_skills(result_text)
    tracked_seen = seen & TRACKED
    builtins_seen = seen - TRACKED

    missing = case.expect_present - seen
    leaked = case.expect_absent & seen
    ok = not missing and not leaked

    status = "PASS" if ok else "FAIL"
    print(f"  [{status}] tracked={sorted(tracked_seen)} builtins={len(builtins_seen)}")
    if missing:
        print(f"         missing (expected present): {sorted(missing)}")
    if leaked:
        print(f"         leaked  (expected absent):  {sorted(leaked)}")
    if case.note:
        print(f"         note: {case.note}")
    if not ok:
        print(f"         raw model output:\n{indent(result_text)}")
    return ok


def indent(s: str | None) -> str:
    if s is None:
        return "           <None>"
    return "\n".join(f"           {line}" for line in s.splitlines())


def build_cases() -> list[Case]:
    return [
        Case(
            name="1. nothing set",
            opts=dict(),
            expect_present={"alpha", "beta", "gamma"},
            expect_absent={"delta"},
            note="CLI default: all sources discovered, SDK adds nothing",
        ),
        Case(
            name="2. old manual way",
            opts=dict(
                allowed_tools=["Skill", "Read"],
                setting_sources=["user", "project"],
            ),
            expect_present={"alpha", "beta", "gamma"},
            expect_absent={"delta"},
        ),
        Case(
            name="3. skills='all' auto-wired",
            opts=dict(skills="all", allowed_tools=["Read", "Bash"]),
            expect_present={"alpha", "beta", "gamma"},
            expect_absent={"delta"},
            note="should match #2 without writing Skill or setting_sources",
        ),
        Case(
            name="4. subset only",
            opts=dict(skills=["alpha", "gamma"], allowed_tools=["Read", "Bash"]),
            expect_present={"alpha", "gamma"},
            expect_absent={"beta", "delta"},
        ),
        Case(
            name="5. subset + setting_sources override",
            opts=dict(skills=["alpha"], setting_sources=["project"]),
            expect_present={"alpha"},
            expect_absent={"beta", "gamma", "delta"},
        ),
        Case(
            name="6. anti-pattern: subset + bare Skill",
            opts=dict(skills=["alpha"], allowed_tools=["Skill", "Read"]),
            expect_present={"alpha"},
            expect_absent={"beta", "gamma", "delta"},
            note="listing should still be filtered; bare Skill only widens invocation perms",
        ),
        Case(
            name="7. plugin skill via allowlist",
            opts=dict(
                plugins=[{"type": "local", "path": PLUGIN}],
                skills=["isolated:delta"],
                allowed_tools=["Read"],
            ),
            expect_present={"delta"},
            expect_absent={"alpha", "beta", "gamma"},
            note="allowlist matches plugin-qualified names; same filter mechanism as case 4",
        ),
        Case(
            name="4b. empty list (degenerate)",
            opts=dict(skills=[], allowed_tools=["Read"]),
            expect_present=set(),
            expect_absent=TRACKED,
        ),
        Case(
            name="4c. nonexistent skill name",
            opts=dict(skills=["nonexistent"], allowed_tools=["Read"]),
            expect_present=set(),
            expect_absent=TRACKED,
            note="filter to a name that isn't on disk → nothing advertised",
        ),
        Case(
            name="8. subagent skills (AgentDefinition)",
            opts=dict(
                skills="all",
                agents={
                    "reporter": AgentDefinition(
                        description="reporter",
                        prompt="You are reporter.",
                        skills=["alpha", "gamma"],
                    )
                },
            ),
            expect_present={"alpha", "beta", "gamma"},
            expect_absent={"delta"},
            note="main session unfiltered; subagent filter not exercised by this prompt",
        ),
    ]


async def main() -> None:
    env = hermetic_env()
    cases = build_cases()
    passed = 0
    for case in cases:
        print(f"\n=== {case.name} ===")
        print(f"    opts: {case.opts}")
        if await run_case(case, env):
            passed += 1
    print(f"\n{'=' * 50}")
    print(f"RESULT: {passed}/{len(cases)} passed")


if __name__ == "__main__":
    os.environ["CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK"] = "1"
    asyncio.run(main())

@jsham042 jsham042 marked this pull request as draft April 16, 2026 02:25
@jsham042 jsham042 marked this pull request as ready for review April 16, 2026 02:25
The CLI's own defaults still apply when the SDK omits --setting-sources,
so None is 'no auto-configuration' rather than 'disabled'. Point users
at [] if they want every skill suppressed from the listing.
…-option

# Conflicts:
#	src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request]: Support skills loading scope

4 participants