diff --git a/AGENTS.md b/AGENTS.md
index 2506cd46..a87efea7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -164,7 +164,7 @@ step-by-step checklist.
- **Set step typing**: `output_type` defaults to `auto` (safe YAML parse with `_to_json_safe` normalisation — `datetime`/`date`/`time` → ISO 8601, non-string dict keys and other non-JSON-safe values raise `ExecutionError`). Explicit `string`/`number`/`integer`/`boolean`/`list`/`dict` only valid on single `value:`. `WorkflowContext.store` accepts any JSON-safe value (scalars/lists from `set` steps in addition to the dicts produced by LLM / script / gate / parallel-group outputs); `_add_agent_input` returns the scalar verbatim for `step.output` and raises a clear `KeyError` for `step.output.field` shorthand on non-dict outputs.
- **Reasoning effort**: `runtime.default_reasoning_effort` sets a workflow-wide default; per-agent `reasoning.effort` overrides it. Allowed values: `low`, `medium`, `high`, `xhigh`, `max`. Each provider translates the unified value to its native API (Copilot: `reasoning_effort` on the session, validated against the model's `supported_reasoning_efforts`; Claude: extended thinking with budget mapping low=2048, medium=8192, high=16384, xhigh=32768, max=59904 tokens, with `temperature` coerced to 1.0 and `max_tokens` bumped to fit the budget). `max` is Copilot/Claude-only — the Hermes provider advertises only the first four levels in `CAPABILITIES.reasoning_effort` and re-checks the resolved effort against that tuple at execute time (in addition to the static `conductor validate` cross-check), so `max` is rejected on Hermes both statically and at runtime, including when it only resolves to `max` after Jinja template rendering. See `examples/reasoning-effort.yaml`.
- **Periodic checkpoints** (`runtime.checkpoint`, issue #244): opt-in `CheckpointConfig` (`every_agent: bool`, `every_seconds: int|None`, `keep_last: int=5`; `is_enabled = every_agent or every_seconds is not None`). Off by default → failure-only behavior preserved. `WorkflowEngine._maybe_save_periodic_checkpoint()` is called once at the **top of `_execute_loop`** (single choke point), where prior outputs are committed and `_current_agent_name` is the step *about to run* — so a periodic checkpoint reuses failure-checkpoint `current_agent` semantics and resume continues forward with no special-casing. Gated via the `_periodic_checkpoints_active` property (**root engine only**, `_subworkflow_depth == 0`, + `is_enabled`) and skips the first iteration (`limits.current_iteration == 0`). The save decision is `_periodic_checkpoint_due(now)` (`every_agent` OR `every_seconds` throttle; first save always fires). `_save_checkpoint_on_failure` and the periodic path share `_write_checkpoint(error, trigger)` (which best-effort-guards provider `get_session_ids()` so it never raises). The periodic save wraps write+emit+rotate; on any failure it calls `_record_periodic_checkpoint_failure()` which emits a **`checkpoint_save_failed`** event (consecutive-failure count; surfaced by `ConsoleEventSubscriber` + JSONL + dashboard) so a recovery-reliant user isn't silently left without checkpoints. After a save the engine calls `rotate_periodic_checkpoints`; at a terminal **non-resumable** outcome (clean completion via `run()`/`resume()`, or an explicit `status: failed` terminate) `_cleanup_run_periodic_checkpoints()` deletes the run's periodic checkpoints (an unexpected failure leaves them in place alongside the failure checkpoint). `conductor checkpoint list` shows a `Trigger` column and `—` for periodic rows' error type. See `examples/periodic-checkpoints.yaml` and `docs/workflow-syntax.md` (Periodic Checkpoints section).
-- **Skills**: `runtime.skills: [name, ...]` sets a workflow-wide default list of skills enabled for every provider-backed agent; per-agent `skills: [name, ...]` overrides it (tri-state via list presence: omitted = inherit, `skills: []` = explicit opt-out, `skills: [name, ...]` = explicit set). Skill names must resolve to a registered built-in (currently just `conductor`). The observable contract is the same across providers — *"the agent has access to the named skill"* — but the mechanism differs by provider via `AgentProvider.supports_native_skills`: **Copilot** (`True`) registers the skill directory on the SDK session via `skill_directories`, so the agent discovers and loads skill content natively (progressive disclosure via `SKILL.md` frontmatter); **Claude** and **Claude Agent SDK** (`False`) eagerly inject every enabled skill's `SKILL.md` plus `references/*.md` into the agent's rendered prompt inside `...` tags. Providers also declare `skills: bool` on their `ProviderCapabilities` descriptor so `conductor validate` can catch skills-against-unsupported-provider mismatches. Built-in skills live under `plugins/conductor/skills//` and are bundled into the wheel via the hatchling `force-include` entry in `pyproject.toml`. Skills are rejected on non-provider-backed step types (script, wait, set, terminate, workflow, human_gate). See `examples/skills-self-improving-workflow.yaml`.
+- **Skills**: `runtime.skills: [name, ...]` sets a workflow-wide default list of skills enabled for every provider-backed agent; per-agent `skills: [name, ...]` overrides it (tri-state via list presence: omitted = inherit, `skills: []` = explicit opt-out, `skills: [name, ...]` = explicit set). Skill names must resolve to a registered built-in (currently just `conductor`). The observable contract is the same across providers — *"the agent has access to the named skill"* — but the mechanism differs by provider via `AgentProvider.supports_native_skills`: **Copilot** (`True`) registers the skill directory on the SDK session via `skill_directories`, so the agent discovers and loads skill content natively (progressive disclosure via `SKILL.md` frontmatter); **Claude Agent SDK** (`True`) is also native but goes through the Claude Code *plugin* surface — `providers/claude_agent_sdk.py::_resolve_skill_plugins` maps each resolved skill directory back to the plugin that owns it (`skills/registry.py::resolve_skill_plugin` walks up for `.claude-plugin/plugin.json`), registers that root via `ClaudeAgentOptions.plugins` and enables the skill by its `:` name via `ClaudeAgentOptions.skills`; **Claude** (`False`) eagerly injects every enabled skill's `SKILL.md` plus `references/*.md` into the agent's rendered prompt inside `...` tags. Providers also declare `skills: bool` on their `ProviderCapabilities` descriptor so `conductor validate` can catch skills-against-unsupported-provider mismatches. Built-in skills live under `plugins/conductor/skills//` and are bundled into the wheel via the hatchling `force-include` entries in `pyproject.toml` — both the skill body **and** `plugins/conductor/.claude-plugin/`, because without the manifest no plugin root resolves and every skills-enabled agent on `claude-agent-sdk` fails with a `ProviderError`. Skills are rejected on non-provider-backed step types (script, wait, set, terminate, workflow, human_gate). See `examples/skills-self-improving-workflow.yaml`.
- **Terminate steps** (`type: terminate`): explicit terminal step with `status` (`success` | `failed`), Jinja2 `reason`, and optional `output_template` (a `dict[str, str]` that replaces `workflow.output:` when set; each value is rendered then passed through `_maybe_parse_json` so `"true"` becomes `True`, `"42"` becomes `42`, JSON literals are parsed). Reaching a terminate step ends the workflow immediately (no routes evaluated after). `success` → CLI exit 0, dashboard ✅, `workflow_completed { termination_reason, terminated_by, is_explicit: true, status }`; runs `on_complete` hook. `failed` → CLI exit 1 (with rendered output JSON still printed to stdout for downstream tooling), dashboard ❌, raises `WorkflowTerminated` (subclass of `ExecutionError`), emits `workflow_failed { error_type: "WorkflowTerminated", is_explicit: true, status, output }`, runs `on_error` hook, and **does not** save an on-failure checkpoint (explicit terminations are intentionally non-resumable). Terminate steps cannot have `routes`, `tools`, `output`, `prompt`, `model`, etc.; cannot be used as parallel-group members or as a for_each inline agent (route to one from those groups' `routes:` instead). Inside a sub-workflow, a `status: failed` terminate is downgraded at the parent boundary to `SubworkflowTerminatedError` (also a subclass of `ExecutionError`) preserving the child's rendered `terminated_output` / `terminated_reason` / `terminated_by` as structured attributes — the parent treats it as a normal sub-workflow failure (its own `workflow_failed` does NOT inherit `is_explicit: true`). For more detail see `examples/terminate.yaml`, `docs/workflow-syntax.md` (Terminate Steps section), and `plugins/conductor/skills/conductor/references/authoring.md`.
- **Structured `runtime.provider` (Copilot custom routing)**: `runtime.provider` accepts either the bare string shorthand (`provider: copilot`) or a structured `ProviderSettings` object that routes the Copilot SDK at OpenAI-compatible / Azure / Anthropic endpoints (Ollama, vLLM, LM Studio, Azure OpenAI, etc.). Object fields: `name` (defaults to `copilot`), `type` (`openai`|`azure`|`anthropic`), `wire_api` (`completions`|`responses`), `base_url`, `api_key`, `bearer_token`, `headers`, `azure.api_version`. `api_key` and `bearer_token` are `SecretStr` (redacted in `model_dump` / dashboard / event logs). The model is frozen after construction. Custom routing activates only when at least one non-`name` field is set in YAML — ambient `OPENAI_*` env vars never divert default routing on their own. Once activated, missing fields fall back from env vars in this order: `base_url` ← `COPILOT_PROVIDER_BASE_URL` → `OPENAI_BASE_URL`; `api_key` ← `COPILOT_PROVIDER_API_KEY` (only — ambient `OPENAI_API_KEY` is intentionally NOT a fallback to avoid credential leaks); `bearer_token` ← `COPILOT_PROVIDER_BEARER_TOKEN`. The schema rejects every non-`name` field when `name != "copilot"` (structured config for other providers is a follow-up). It also rejects anchorless / broken combinations that would silently no-op at the SDK boundary: `wire_api` / `type` / `headers` / `azure` cannot stand alone without `base_url` / `api_key` / `bearer_token`; empty `headers`, empty `SecretStr`, and `azure: {api_version: null}` are rejected. The resolver raises `ProviderError` when custom routing is activated but every resolved field is falsy (e.g. expected env vars all unset). Custom routing applies to both agent execution and dialog turns so all sessions hit the same endpoint. `--provider ` CLI override replaces the whole `ProviderSettings` (logs a notice when YAML had structured fields). See `examples/copilot-local-llm.yaml`.
- **Connect to an existing Copilot runtime (Copilot)**: `runtime.provider.runtime_url` (Copilot-only) points the provider at an already-running `copilot --headless` process instead of spawning a nested one. Agents share the authenticated runtime process while retaining separate SDK sessions. Optional `runtime_token` (`SecretStr`, redacted, requires `runtime_url`) is the socket connection secret. Both fields fall back to env vars (`COPILOT_PROVIDER_RUNTIME_URL` / `COPILOT_PROVIDER_RUNTIME_TOKEN`) which activate the connection on their own (zero-YAML path for external orchestrators). `has_external_runtime()` is a separate axis from `has_custom_routing()`; the two can be combined because runtime transport and per-session model routing are independent. `has_structured_config()` keeps either mode from collapsing to bare-string serialization. Schema rejects: `runtime_token` without `runtime_url`; empty or whitespace-only runtime values; either field when `name != "copilot"`. Provider layer: `_resolve_runtime_connection()` (YAML then env) and `_build_client()` (in `copilot.py`). See `examples/copilot-existing-runtime.yaml` and `docs/configuration.md` (Connecting to an Existing Copilot Runtime).
@@ -268,13 +268,18 @@ Conductor:
- The config is written to a `0600` temp file (`_write_mcp_config`) and passed **by path**. Passing the dict would make the SDK serialize it into a `--mcp-config ` argv element, publishing resolved stdio `env` values and http/sse `Authorization` headers to anything that can read `/proc//cmdline`. The write happens **inside** `execute`'s `try`, so the `finally` reclaims the file on every exit path; the finally also `aclose()`s the SDK iterator first, so the `claude` subprocess is gone before its config file is. The file must use the `{"mcpServers": {...}}` envelope — the CLI rejects a bare mapping.
- `strict_mcp_config=True` is set **unconditionally**, including when the workflow declares no servers: otherwise the CLI loads project `.mcp.json`, user-global, and plugin-provided servers, and `permission_mode` bypasses approval for whatever they expose.
- A narrowing per-server `tools:` filter (anything other than the default `["*"]`) is **refused**, not ignored: forwarding the server unfiltered would grant more tools than declared, the same security regression that justifies refusing the per-agent allowlist. A dropped `timeout` only warns, since losing it cannot widen tool access.
-- **Tool execution**: Per-agent `tools:` allowlists remain unsupported (`workflow_tools_passthrough=False`). The provider refuses any non-empty per-agent list because workflow tool names do not translate to CLI tool IDs. Note the SDK's `tools` option governs **built-in** tools only, and `allowed_tools` is a permission auto-approve list rather than an availability filter — so honoring an allowlist would require a permission-mode redesign, not just a name mapping. An agent with `tools: []` runs with no built-in tools (MCP servers still attach); omitting `tools:` grants the full `claude_code` preset.
+- **Tool execution**: Per-agent `tools:` allowlists remain unsupported (`workflow_tools_passthrough=False`). The provider refuses any non-empty per-agent list because workflow tool names do not translate to CLI tool IDs. Note the SDK's `tools` option governs **built-in** tools only, and `allowed_tools` is a permission auto-approve list rather than an availability filter — so honoring an allowlist would require a permission-mode redesign, not just a name mapping. An agent with `tools: []` runs with no built-in tools beyond the `Skill` loader when skills are enabled (MCP servers still attach); omitting `tools:` grants the full `claude_code` preset.
- **Runtime config**: `temperature` and `max_tokens` are rejected at the factory — the CLI controls sampling behavior.
- **Working directory** (issue #348): the engine-resolved `agent.working_dir` / `runtime.working_dir` **is** forwarded, as `ClaudeAgentOptions.cwd`.
- The SDK applies it as the `claude` subprocess's cwd (`_internal/transport/subprocess_cli.py` as of 0.2.87 passes it to `open_process` and sets `PWD`), so stdio MCP servers pick it up by **inheriting** it from that subprocess. There is deliberately no per-server stamping as in `copilot.py::_mcp_servers_for_cwd`: the SDK's `McpStdioServerConfig` has no cwd field, so `_translate_mcp_servers` is left alone. Inheritance is a property of the CLI binary, not of the SDK, so it is documented rather than asserted by a test.
- The path is passed **verbatim** — `WorkflowEngine._resolve_agent_working_dir` has already rendered, absolutized, normalised, and existence-checked it, and re-resolving here would collapse the symlink aliases the engine preserves on purpose. The `ClaudeAgentOptions(...)` construction lives **inside** `execute`'s `try` so the `os.getcwd()` fallback can't escape as a bare `OSError` when the process cwd has been deleted (`copilot.py` resolves its cwd inside its try for the same reason).
- There is no provider-side `is_dir()` guard: a directory that vanishes after the engine's check surfaces as the SDK's `CLIConnectionError("Working directory does not exist: ")`, wrapped in `ProviderError`. That is only defensible because `_classify_startup_failure` special-cases it — `CLIConnectionError` otherwise yields firewall/binary advice and `is_retryable=True`, which is wrong for all three launch failures (missing dir; path is a file → `ENOTDIR`; unreadable dir → `EACCES`; the latter two reach the SDK's generic "Failed to start Claude Code" arm, not its dedicated one).
- - Knock-on effects: cwd selects which `CLAUDE.md` and local settings the CLI loads (Conductor never sets `setting_sources`, so the CLI's load-everything default applies) and is the project key for the CLI's on-disk transcript directory. The unconditional `strict_mcp_config=True` still stops a `.mcp.json` in that directory from injecting undeclared servers, but it does **not** cover hooks or instructions — point `working_dir` only at trees you trust. `add_dirs` (the SDK's `--add-dir` passthrough) is a separate axis Conductor does not set.
+ - Knock-on effects: cwd is the project key for the CLI's on-disk transcript directory, and it is where a project `.mcp.json` and `.claude/` tree would be looked for. The unconditional `strict_mcp_config=True` stops a `.mcp.json` there from injecting undeclared servers, and the unconditional `setting_sources=[]` (see **Skills** below) stops the CLI loading `CLAUDE.md`, project settings, and hooks from it — so cwd no longer drags ambient instructions in. `add_dirs` (the SDK's `--add-dir` passthrough) is a separate axis Conductor does not set.
+- **Skills** (issue #352): `supports_native_skills=True`. Skills are enabled through the SDK, not prompt injection, and three options move together in `execute`:
+ - `plugins=[{"type": "local", "path": }]` + `skills=[":"]`. The SDK has no skill-*directory* surface, so `_resolve_skill_plugins` maps each directory back to the plugin that owns it via `skills/registry.py::resolve_skill_plugin`. That resolver is deliberately strict, because every one of these mistakes otherwise produces a name the CLI silently resolves to nothing: it bounds the upward walk (`_PLUGIN_SEARCH_DEPTH`), requires the skill to actually live under the candidate's `skills/` directory, requires `SKILL.md` to exist and its frontmatter `name` to equal the directory name (the CLI resolves by frontmatter name; Conductor sends the directory name), and rejects names outside `[A-Za-z0-9_.-]+` since they are joined into a comma-delimited `--allowedTools` value. A skill under no plugin root returns `None`; a plugin that is present but unusable raises `SkillPluginError`, which the provider re-raises as a `ProviderError` carrying the real reason rather than a blanket "not part of a plugin". Two plugins claiming one qualified name are refused too — deduping the clash away would drop a declared skill. All of it is `is_retryable=False`: these never become valid on a retry, and a checkout path containing "connection" would otherwise trip the retryability heuristic. Note providers are constructed lazily, so this surfaces when the first agent on this provider runs, **not** at `conductor validate`.
+ - `setting_sources=[]` **unconditionally**, for the same reason `strict_mcp_config=True` is unconditional a few lines away. Left unset, the CLI loads user, project, and local settings, which between them bring in ambient skills, `CLAUDE.md`, and hooks the workflow never declared, varying by machine and launch directory. Conductor surfaces instruction files through its own opt-in `--workspace-instructions`; settings and hooks have no equivalent. `skills=[]` and `skills=None` are **not** interchangeable upstream: `None` means "CLI defaults apply", and setting `skills` while leaving `setting_sources` at `None` makes the SDK re-default it to `["user", "project"]` — so the two options are coupled and dropping either re-opens the issue. The `[]` is also invisible in argv (it travels in the SDK's `initialize` control request), which is why the argv-based tests are paired with options-level assertions. The list is a context filter, not a sandbox: undeclared skills are hidden from the model's listing but their files stay readable.
+ - An explicit `tools: []` sends `--tools ""` (empty base tool set), which would leave a declared skill unreachable; `_resolve_tool_config` therefore grants back the single `Skill` tool when skills are enabled. No permission bypass is needed — the SDK auto-allows it via `Skill()` in `allowed_tools`.
+ - The executor→provider seam (`executor/agent.py`) is the only thing carrying the feature now that eager injection no longer backs it up on this provider, so `tests/test_skills/test_executor_integration.py::TestSkillDirectoriesReachTheProvider` asserts directories actually arrive at `execute`. A negative "no `` in the prompt" assertion cannot tell a working native path from one that dropped the skills entirely.
#### `aca.py` parity notes
@@ -392,8 +397,9 @@ descriptor undermines the framework.
- Declare accurate `ProviderCapabilities` matching observed behavior.
- Declare `skills` accurately. Skills are **not** an allowed carve-out — a
provider reaches `skills=True` either natively (`supports_native_skills=True`,
- forwarding `skill_directories` to its SDK) or via `AgentExecutor`'s eager
- preamble injection, which is provider-agnostic. Declare `False` only when
+ forwarding the resolved skill directories to its SDK in whatever shape that
+ SDK accepts) or via `AgentExecutor`'s eager preamble injection, which is
+ provider-agnostic. Declare `False` only when
neither path can work (e.g. `aca`, where skill directories are host paths the
in-sandbox runner cannot read). `config/validator.py` cross-checks per-agent
`skills:` and inherited `runtime.skills` against this flag, so an inaccurate
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 685be147..4e1336ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,9 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
field — inheritance from the CLI subprocess covers it. A missing directory
still fails before the provider is reached, and `strict_mcp_config` remains
enabled so a `.mcp.json` sitting in the new directory cannot inject
- undeclared servers. Note that the `claude` CLI also reads `CLAUDE.md` and
- `.claude/settings*.json` from its working directory, so pointing an agent at
- an untrusted checkout means running that checkout's instructions and hooks.
+ undeclared servers. The `claude` CLI would also read `CLAUDE.md` and
+ `.claude/settings*.json` from its working directory, but the same release
+ pins `setting_sources` to an empty list (see the skills entry below), so
+ those are no longer loaded from wherever the agent happens to run.
Launch failures caused by a bad working directory are now reported as such
rather than as connection problems, and are no longer treated as retryable.
See
@@ -80,6 +81,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- **`skills: []` is now a real opt-out on `claude-agent-sdk`, and agents no
+ longer inherit ambient skills from the machine.** The provider left the SDK's
+ `setting_sources` unset, so the `claude` CLI discovered and enabled skills
+ from `~/.claude/skills/`, every `.claude/skills/` up the directory tree, and
+ enabled plugins — none of which the workflow declared, and all of which
+ varied by developer machine and launch directory. Conductor documents
+ `skills: []` as an explicit opt-out; on this provider it silently opted out
+ of nothing. Two options now carry that fix together and neither is redundant:
+ `setting_sources` is always `[]` (the same unconditional isolation
+ `strict_mcp_config` already applies to MCP servers), and `skills` is always
+ passed explicitly, because the SDK treats an omitted list as "CLI defaults
+ apply" and re-defaults `setting_sources` to `["user", "project"]` whenever
+ `skills` is set without it.
+ **Behavior change:** agents on this provider also stop picking up ambient
+ `CLAUDE.md`, `.claude/rules/*.md`, user/project/local `settings.json`
+ (including `env` and `apiKeyHelper`), and hooks. Instruction files can be
+ supplied explicitly with `--workspace-instructions` (or `--instructions`);
+ settings and hooks have no equivalent, so move anything load-bearing there
+ into the environment. Note the SDK's skill list is a context filter, not a
+ sandbox — undeclared skills are hidden from the model's listing, but their
+ files stay readable on disk.
+ ([#352](https://github.com/microsoft/conductor/issues/352))
+
- **`tools: []` no longer fails validation when no MCP servers are declared** —
the capability cross-check rejected an explicit empty allowlist against any
provider with `mcp_tools=True` and `workflow_tools_passthrough=False` (such
@@ -113,6 +137,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- **`claude-agent-sdk` now loads skills natively instead of injecting them into
+ every prompt.** The provider previously took the eager preamble path on the
+ grounds that the SDK had no skill surface — out of date, and expensive: the
+ full `SKILL.md` plus the entire `references/` tree was prepended to every
+ call, every retry, and every validator pass (~27K tokens for the bundled
+ `conductor` skill). The owning Claude Code plugin is now registered on the
+ session and the skill enabled by its `:` name, so the CLI reads
+ only the frontmatter up front and loads the body on demand. An agent with an
+ explicit `tools: []` is granted back the single `Skill` tool when it has
+ skills enabled, since an empty base tool set would otherwise leave the
+ declared skill unreachable. Wheels now also ship
+ `plugins/conductor/.claude-plugin/`; without the manifest no plugin root
+ resolves at all, so a non-editable install would fail every skills-enabled
+ agent on this provider.
+ ([#352](https://github.com/microsoft/conductor/issues/352))
+
- The `claude-agent-sdk` optional dependency floor is now
`claude-agent-sdk>=0.2.82` — the 0.2.x line is what Conductor tests against.
([#335](https://github.com/microsoft/conductor/issues/335))
diff --git a/docs/providers/comparison.md b/docs/providers/comparison.md
index bcc6e121..6d25b830 100644
--- a/docs/providers/comparison.md
+++ b/docs/providers/comparison.md
@@ -126,10 +126,37 @@ agents:
The `claude-agent-sdk` provider bridges MCP servers into the CLI, but not per-agent tool allowlists. Concretely:
- `runtime.mcp_servers` — **supported**. Servers are translated into the SDK's MCP config and attach alongside the built-in preset. Only declared servers attach: Conductor sets `strict_mcp_config`, so ambient Claude Code MCP settings are ignored. A narrowing per-server `tools:` filter is refused, since the SDK has no equivalent field.
-- Per-agent `tools: []` — disables the built-in tools for that agent. Declared MCP servers still attach, so this combination is rejected at `conductor validate` when the workflow declares `mcp_servers`.
+- Per-agent `tools: []` — disables the built-in tools for that agent, except the `Skill` loader when the agent has skills enabled (an empty tool set would otherwise leave a declared skill unreachable). Declared MCP servers still attach, so this combination is rejected at `conductor validate` when the workflow declares `mcp_servers`.
- Per-agent `tools: [list]` — **refused loudly**. Workflow tool names do not translate to Claude CLI tool IDs; silently passing them through would risk granting the wrong native tool.
- Workflow-level `tools:` combined with an agent that omits `tools:` — **rejected at `conductor validate`**. The agent would otherwise inherit that non-empty list at runtime and hit the same refusal with a confusing message. Remove the workflow-level `tools:` (so omitting `tools:` grants the preset) or set the agent's `tools: []`.
- Omitting `tools:` entirely (with no workflow-level `tools:`) — grants the full `claude_code` preset (filesystem, bash, web), matching the bare `claude` CLI experience.
+
+### Important: Skills and ambient settings
+
+Skills are loaded natively: the Claude Code plugin that ships a declared skill is
+registered on the session and the skill enabled by its `:` name, so
+the CLI reads only the `SKILL.md` frontmatter up front and the body on demand.
+
+Conductor also pins the SDK's `setting_sources` to an empty list on every run — the
+skills counterpart to `strict_mcp_config`. Without it the `claude` CLI enables skills
+the workflow never declared, varying by machine and launch directory, which made
+`skills: []` a no-op on this provider.
+
+That isolation is broader than skills. Agents on this provider do **not** pick up:
+
+- ambient skills from `~/.claude/skills/` or any `.claude/skills/` directory
+- `CLAUDE.md` and `.claude/rules/*.md`
+- user, project, and local `settings.json` (including `env` and `apiKeyHelper`)
+- hooks
+
+Instruction files have a replacement: run with `--workspace-instructions` (or
+`--instructions `) to inject `AGENTS.md` / `CLAUDE.md` explicitly. Settings and
+hooks have none — if you rely on `apiKeyHelper` for credentials, supply them through
+the environment instead.
+
+Note the SDK treats the enabled-skill list as a context filter rather than a sandbox:
+undeclared skills are hidden from the model's listing and rejected by the `Skill`
+tool, but their files remain readable on disk through `Read`/`Bash`.
- `temperature` and `max_tokens` are **rejected at the factory** — sampling behavior is controlled by the CLI.
### Example Claude Agent SDK Workflow
diff --git a/docs/providers/experimental.md b/docs/providers/experimental.md
index ad900f24..ce66152d 100644
--- a/docs/providers/experimental.md
+++ b/docs/providers/experimental.md
@@ -99,7 +99,7 @@ adopting one does not inflate the install surface for others.
| Provider | Upstream pin | Maintainer | Capability carve-outs |
|---|---|---|---|
-| `claude-agent-sdk` | `claude-agent-sdk>=0.2.82` | `@lesandiz (best-effort)` | no `workflow_tools_passthrough`, no `reasoning_effort`, `prompt_injection` structured output, no `checkpoint_resume`. Supports `mcp_tools` as of [#335](https://github.com/microsoft/conductor/issues/335), except that a narrowing per-server `tools:` filter is refused (no SDK equivalent). Supports `working_dir` as of [#348](https://github.com/microsoft/conductor/issues/348) — note the `claude` CLI also loads `CLAUDE.md` and `.claude/settings*.json` from that directory, so point it only at trees you trust. |
+| `claude-agent-sdk` | `claude-agent-sdk>=0.2.82` | `@lesandiz (best-effort)` | no `workflow_tools_passthrough`, no `reasoning_effort`, `prompt_injection` structured output, no `checkpoint_resume`. Supports `mcp_tools` as of [#335](https://github.com/microsoft/conductor/issues/335), except that a narrowing per-server `tools:` filter is refused (no SDK equivalent). Supports `working_dir` as of [#348](https://github.com/microsoft/conductor/issues/348); the CLI would load `CLAUDE.md` and `.claude/settings*.json` from that directory, but `setting_sources` is pinned empty as of [#352](https://github.com/microsoft/conductor/issues/352) so ambient instructions, settings, hooks, and skills are not inherited. |
| `hermes` | `hermes-agent` | `(community contribution)` | no `mcp_tools`, `prompt_injection` structured output, no `working_dir` |
| `aca` | `azure-identity>=1.19.0` | `(unassigned)` | no `workflow_tools_passthrough` (the wrapped in-container `CopilotProvider` never applies the `tools:` allowlist to the SDK session), no `working_dir` (only the separate, container-relative `sandbox.working_dir` is honored — not the generic host-resolved field), `prompt_injection` structured output (inherits the inner Copilot provider), no `checkpoint_resume` (ephemeral sandbox sessions, no volume mount). Declares `interrupt`/`max_session_seconds` as `True`, but the shipped runner MVP doesn't fully back either yet — see [Known Gaps](./aca.md#known-gaps-runner-mvp). |
diff --git a/examples/skills-self-improving-workflow.yaml b/examples/skills-self-improving-workflow.yaml
index df49dd43..25fc9a25 100644
--- a/examples/skills-self-improving-workflow.yaml
+++ b/examples/skills-self-improving-workflow.yaml
@@ -11,6 +11,9 @@
# * Copilot: the skill directory is registered on the SDK session via
# `skill_directories`. The agent discovers and loads SKILL.md and
# references on demand (progressive disclosure).
+# * Claude Agent SDK: also native, via the Claude Code plugin surface —
+# the plugin shipping the skill is registered and the skill enabled
+# by its `:` name.
# * Claude: SKILL.md + references/*.md are eagerly prepended to the
# agent's rendered prompt inside
# ... tags.
diff --git a/plugins/conductor/skills/conductor/references/authoring.md b/plugins/conductor/skills/conductor/references/authoring.md
index 7afefd1b..7edd57b4 100644
--- a/plugins/conductor/skills/conductor/references/authoring.md
+++ b/plugins/conductor/skills/conductor/references/authoring.md
@@ -229,6 +229,7 @@ See `examples/validator.yaml` for a complete example.
**Provider mechanism (same observable contract — "the agent has access to the named skill"):**
- **Copilot** — the resolved skill directory is registered on the SDK session via `skill_directories`, so the agent discovers and loads skill content natively (progressive disclosure via `SKILL.md` frontmatter). This is more token-efficient than eager injection.
+- **Claude Agent SDK** — also native, through the Claude Code plugin surface: the plugin owning the skill is registered on the session and the skill enabled by its `:` name. Skills the workflow did not declare are suppressed, so `skills: []` really is an opt-out and ambient skills from the machine never load.
- **Claude** — the loader reads `SKILL.md` plus every `references/*.md` file in the skill directory and prepends them to the agent's rendered prompt inside `...` tags. Inserted between workspace instructions and the user prompt.
Not allowed on `script`, `human_gate`, `workflow`, `wait`, `set`, or `terminate` agent types. Unknown skill names fail at workflow validation time.
diff --git a/plugins/conductor/skills/conductor/references/yaml-schema.md b/plugins/conductor/skills/conductor/references/yaml-schema.md
index c83d1a06..076e3b4e 100644
--- a/plugins/conductor/skills/conductor/references/yaml-schema.md
+++ b/plugins/conductor/skills/conductor/references/yaml-schema.md
@@ -39,6 +39,7 @@ workflow:
skills: [string] # Skills enabled for every provider-backed agent (default: [])
# Currently registered built-ins: "conductor"
# Copilot loads natively via `skill_directories`;
+ # claude-agent-sdk loads natively via its plugin surface;
# Claude eagerly injects SKILL.md + references/*.md into the prompt.
mcp_servers: # MCP server configurations (ignored by claude-agent-sdk — uses CLI config)
:
diff --git a/pyproject.toml b/pyproject.toml
index 8b6d280e..faccf8f2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -85,6 +85,12 @@ exclude = [
[tool.hatch.build.targets.wheel.force-include]
"plugins/conductor/skills/conductor" = "plugins/conductor/skills/conductor"
+# The plugin manifest, not just the skill body: the claude-agent-sdk provider
+# loads skills by registering `plugins/conductor` as a Claude Code plugin
+# (`--plugin-dir`), which requires `.claude-plugin/plugin.json` to be present.
+# Without it no plugin root resolves, so the skill works from a source checkout
+# and every skills-enabled agent fails with a ProviderError on a wheel install.
+"plugins/conductor/.claude-plugin" = "plugins/conductor/.claude-plugin"
[dependency-groups]
dev = [
diff --git a/src/conductor/config/schema.py b/src/conductor/config/schema.py
index e546379e..40f2bc1c 100644
--- a/src/conductor/config/schema.py
+++ b/src/conductor/config/schema.py
@@ -1092,11 +1092,17 @@ class AgentDef(BaseModel):
* **Copilot** — skill directories are passed to the SDK session via
``skill_directories``; the model discovers and loads skill content
as relevant (progressive disclosure, token-efficient).
- * **Claude / Claude Agent SDK** — ``SKILL.md`` plus
- ``references/*.md`` is eagerly injected into the agent's rendered
- prompt, wrapped in ```` tags. There is no native
- skill surface on the Anthropic API without adopting the
- container/code-execution beta.
+ * **Claude Agent SDK** — the Claude Code plugin that owns the skill is
+ registered on the session and the skill is enabled by its
+ ``:`` name, so the CLI loads only the ``SKILL.md``
+ frontmatter up front. Skills the workflow did not declare are
+ filtered out of the model's listing instead of being inherited
+ from the machine.
+ * **Claude** — ``SKILL.md`` plus ``references/*.md`` is eagerly
+ injected into the agent's rendered prompt, wrapped in
+ ```` tags. There is no native skill surface on
+ the Anthropic API without adopting the container/code-execution
+ beta.
Tri-state semantics via list presence:
@@ -2511,8 +2517,11 @@ def _coerce_provider(cls, value: Any) -> Any:
Skill content reaches the model differently per provider:
* **Copilot** — registered on the SDK session via ``skill_directories``
- * **Claude / Claude Agent SDK** — eagerly injected into the rendered
- prompt inside ``...`` tags
+ * **Claude Agent SDK** — the owning plugin is registered via
+ ``--plugin-dir`` and the skill enabled by its ``:``
+ name, so the CLI loads it on demand
+ * **Claude** — eagerly injected into the rendered prompt inside
+ ``...`` tags
Defaults to an empty list (no skills). Phase 1 ships one built-in
skill (``conductor``); user-defined skill directories will be added
diff --git a/src/conductor/config/validator.py b/src/conductor/config/validator.py
index 2e613406..1b9d6f7b 100644
--- a/src/conductor/config/validator.py
+++ b/src/conductor/config/validator.py
@@ -1617,7 +1617,8 @@ def _check_agent_tools(agent: AgentDef, provider_name: str, caps: ProviderCapabi
``workflow_tools_passthrough=False`` — ``aca``, whose in-container
runner attaches every configured MCP server unconditionally, and
``claude-agent-sdk``, where ``tools: []`` disables only the built-in
- CLI tools). There is no allowlist value, empty or not, those
+ CLI tools — bar the ``Skill`` loader when the agent declares
+ skills). There is no allowlist value, empty or not, those
providers can honor, so ``tools: []`` would misleadingly pass
validation while every MCP tool stays attached. This only applies
when the workflow actually declares ``mcp_servers``: with nothing to
@@ -1653,7 +1654,7 @@ def _check_agent_tools(agent: AgentDef, provider_name: str, caps: ProviderCapabi
f"(capabilities.workflow_tools_passthrough=False). Remove the "
f"workflow-level 'tools:' so omitting 'tools:' grants the "
f"provider's default tool preset, or set this agent's "
- f"'tools: []' to disable all tools."
+ f"'tools: []' to disable the built-in tools."
)
def _check_agent_capabilities(
diff --git a/src/conductor/executor/agent.py b/src/conductor/executor/agent.py
index 897e8b8f..caef1185 100644
--- a/src/conductor/executor/agent.py
+++ b/src/conductor/executor/agent.py
@@ -292,9 +292,10 @@ async def execute(
_verbose_log(f" Tools: {resolved_tools}")
# Resolve skill directories for providers with native skill support
- # (Copilot passes these on session_kwargs; Claude has already had
- # the skill content eager-injected into rendered_prompt above and
- # ignores this).
+ # (Copilot passes these on session_kwargs, claude-agent-sdk maps them
+ # to plugin + skill-name options; providers without native support
+ # have already had the skill content eager-injected into
+ # rendered_prompt above and ignore this).
skill_dirs: list[str] | None = None
if getattr(self.provider, "supports_native_skills", False):
skill_names = self._resolve_skills_for_agent(agent)
@@ -302,6 +303,7 @@ async def execute(
from conductor.skills import resolve_skill_directories
skill_dirs = [str(p) for p in resolve_skill_directories(skill_names)]
+ _verbose_log(f" Skills: {skill_names}")
# Execute via provider
output = await self.provider.execute(
diff --git a/src/conductor/providers/base.py b/src/conductor/providers/base.py
index c6ade6f4..8058f11c 100644
--- a/src/conductor/providers/base.py
+++ b/src/conductor/providers/base.py
@@ -223,7 +223,8 @@ def supports_native_skills(self) -> bool:
passes resolved skill directories to :meth:`execute` via
``skill_directories`` and skips eager preamble injection — the
provider's SDK is expected to discover and load skill content
- itself (e.g. Copilot's session-level ``skill_directories``).
+ itself (e.g. Copilot's session-level ``skill_directories``, or
+ the claude-agent-sdk's plugin-scoped ``skills`` option).
When ``False`` (default), the executor eagerly injects the full
``SKILL.md`` plus ``references/*.md`` content into the agent's
diff --git a/src/conductor/providers/capabilities.py b/src/conductor/providers/capabilities.py
index 78784822..26cd1b4a 100644
--- a/src/conductor/providers/capabilities.py
+++ b/src/conductor/providers/capabilities.py
@@ -146,9 +146,12 @@ class ProviderCapabilities(BaseModel):
:attr:`AgentProvider.supports_native_skills`:
* ``supports_native_skills=True`` — resolved skill directories are
- passed to the SDK on the ``skill_directories`` kwarg of
- :meth:`AgentProvider.execute` and the SDK loads skill content
- itself (progressive disclosure via ``SKILL.md`` frontmatter).
+ passed on the ``skill_directories`` kwarg of
+ :meth:`AgentProvider.execute` and the provider forwards them to
+ its SDK in whatever shape that SDK accepts (Copilot registers the
+ directories as-is; claude-agent-sdk maps them to plugin roots and
+ qualified skill names). The SDK then loads skill content itself
+ (progressive disclosure via ``SKILL.md`` frontmatter).
* ``supports_native_skills=False`` — :class:`AgentExecutor` reads
every enabled skill's ``SKILL.md`` plus ``references/*.md`` and
eagerly prepends them to ``rendered_prompt`` inside
diff --git a/src/conductor/providers/claude_agent_sdk.py b/src/conductor/providers/claude_agent_sdk.py
index 6b116a18..205ae795 100644
--- a/src/conductor/providers/claude_agent_sdk.py
+++ b/src/conductor/providers/claude_agent_sdk.py
@@ -9,6 +9,7 @@
import os
import tempfile
import time
+from pathlib import Path
from typing import TYPE_CHECKING, Any, Final, cast
from conductor.exceptions import ProviderError
@@ -21,7 +22,10 @@
from conductor.providers.capabilities import ProviderCapabilities
if TYPE_CHECKING:
+ from claude_agent_sdk import SdkPluginConfig # ty: ignore[unresolved-import]
+
from conductor.config.schema import AgentDef, OutputField
+ from conductor.skills import SkillPlugin
try:
from claude_agent_sdk import ClaudeAgentOptions, query # ty: ignore[unresolved-import]
@@ -86,6 +90,12 @@ def _build_output_format(output: dict[str, OutputField]) -> dict[str, Any]:
# workflow-tools copy, which is empty only when the workflow declares no `tools:`.
_DEFAULT_TOOL_PRESET: dict[str, str] = {"type": "preset", "preset": "claude_code"}
+# Native CLI tool that loads an enabled skill on demand. An explicit
+# ``tools: []`` sends ``--tools ""`` (empty base tool set), which would leave a
+# declared skill unreachable, so this one tool is granted back when skills are
+# enabled.
+_SKILL_TOOL: Final[str] = "Skill"
+
# Display-only previews for the verbose CLI pretty-printer (NOT surfaced
# in events — see ``_TOOL_RESULT_PREVIEW_LEN`` below for the on-the-wire
# truncation).
@@ -274,6 +284,97 @@ def _remove_mcp_config(path: str) -> None:
)
+def _resolve_skill_plugins(
+ skill_directories: list[str] | None,
+) -> tuple[list[str], list[SdkPluginConfig]]:
+ """Map resolved skill directories to SDK ``skills`` / ``plugins`` options.
+
+ The SDK has no "skill directory" surface: a skill is enabled by name and
+ discovered through the plugin that owns it. Each directory is therefore
+ resolved back to its Claude Code plugin root, which is registered via
+ ``plugins`` (``--plugin-dir``) and referenced by the plugin-qualified
+ ``:`` name.
+
+ Args:
+ skill_directories: Absolute skill directory paths from
+ :class:`~conductor.executor.agent.AgentExecutor`, or ``None``
+ when no skills are enabled.
+
+ Returns:
+ A ``(skill_names, plugin_configs)`` tuple. The lists are not
+ index-parallel: two skills shipped by one plugin produce two names
+ and a single plugin registration. Both are empty when no skills are
+ enabled — and an empty ``skills`` list is meaningful to the SDK: it
+ suppresses every skill rather than falling back to CLI discovery
+ defaults.
+
+ Raises:
+ ProviderError: If a skill cannot be turned into a name the CLI will
+ resolve — it lives under no plugin root, its plugin manifest is
+ unusable, or two plugins claim the same qualified name. Each of
+ those would otherwise hand the agent less than the workflow
+ declared, silently.
+ """
+ if not skill_directories:
+ return [], []
+
+ from conductor.skills import SkillPluginError, resolve_skill_plugin
+
+ plugins: list[SkillPlugin] = []
+ for directory in skill_directories:
+ try:
+ plugin = resolve_skill_plugin(Path(directory))
+ except SkillPluginError as exc:
+ raise ProviderError(
+ f"Skill directory {directory!r} belongs to a Claude Code plugin that "
+ f"cannot be loaded: {exc}",
+ suggestion=(
+ "Repair the plugin, or run this agent on a provider that loads "
+ "skill directories directly (copilot). A reinstall usually fixes "
+ "this for a built-in skill."
+ ),
+ is_retryable=False,
+ ) from exc
+ if plugin is None:
+ raise ProviderError(
+ f"Skill directory {directory!r} is not part of a Claude Code plugin "
+ "(no .claude-plugin/plugin.json shipping it in the nearest parent "
+ "directories), and claude-agent-sdk can only load skills that a "
+ "plugin provides.",
+ suggestion=(
+ "Package the skill as a plugin, or run this agent on a "
+ "provider that loads skill directories directly (copilot)."
+ ),
+ is_retryable=False,
+ )
+ plugins.append(plugin)
+
+ # Two skills can ship from one plugin: register the root once but keep every
+ # name. Dropping a name would under-serve the workflow, so a genuine clash --
+ # two different roots claiming one qualified name -- is refused rather than
+ # deduped away.
+ claimed: dict[str, Path] = {}
+ for plugin in plugins:
+ prior = claimed.setdefault(plugin.qualified_name, plugin.plugin_root)
+ if prior != plugin.plugin_root:
+ raise ProviderError(
+ f"Two different plugins both provide the skill "
+ f"{plugin.qualified_name!r}: {prior} and {plugin.plugin_root}. The CLI "
+ "cannot tell them apart, so one of the skills this workflow declared "
+ "would be dropped.",
+ suggestion=(
+ "Rename one of them in its .claude-plugin/plugin.json, or enable "
+ "only one of the two."
+ ),
+ is_retryable=False,
+ )
+
+ skill_names = list(claimed)
+ plugin_paths = list(dict.fromkeys(str(p.plugin_root) for p in plugins))
+ logger.debug("Enabling skills %s from plugin roots %s", skill_names, plugin_paths)
+ return skill_names, [{"type": "local", "path": path} for path in plugin_paths]
+
+
class ClaudeAgentSdkProvider(AgentProvider):
"""Claude Agent SDK provider.
@@ -290,8 +391,9 @@ class ClaudeAgentSdkProvider(AgentProvider):
# ambient project/user MCP config is ignored. A narrowing per-server
# ``tools:`` filter has no SDK equivalent and is refused.
mcp_tools=True,
- # Per-agent ``tools: []`` disables all *built-in* tools; declared MCP
- # servers still attach (the SDK has no per-request MCP toggle), which
+ # Per-agent ``tools: []`` disables all *built-in* tools except the
+ # ``Skill`` loader when skills are enabled; declared MCP servers
+ # still attach (the SDK has no per-request MCP toggle), which
# is why the validator rejects ``tools: []`` alongside ``mcp_servers:``
# for this provider. Per-agent ``tools: []`` is refused loudly
# at execute time because workflow tool names do not translate to
@@ -335,10 +437,12 @@ class ClaudeAgentSdkProvider(AgentProvider):
# rather than being stamped individually as they are for Copilot:
# the SDK's ``McpStdioServerConfig`` has no cwd field.
working_dir=True,
- # Skill content is eagerly injected into the rendered prompt by
- # AgentExecutor (the claude-agent-sdk surfaces no
- # ``skill_directories`` kwarg today; once it does we can flip
- # to native via ``supports_native_skills``).
+ # Skills are loaded natively: the owning plugin is registered via
+ # ``ClaudeAgentOptions.plugins`` and enabled by its qualified name
+ # through ``skills``, so the model reads the frontmatter up front
+ # and the body on demand. ``skills`` is also set (to ``[]``) when
+ # the workflow declares none — see the option block in ``execute``
+ # for why that empty list is what makes ``skills: []`` an opt-out.
skills=True,
upstream_pin="claude-agent-sdk>=0.2.82",
maintainer="@lesandiz (best-effort)",
@@ -365,6 +469,20 @@ def __init__(
# the first agent on this provider runs — not at `conductor validate`.
self._mcp_servers = _translate_mcp_servers(mcp_servers) if mcp_servers else {}
+ @property
+ def supports_native_skills(self) -> bool:
+ """Skills load through the SDK, not through prompt injection.
+
+ :class:`~conductor.executor.agent.AgentExecutor` forwards the
+ resolved skill directories on the :meth:`execute`
+ ``skill_directories`` kwarg and skips eager preamble injection.
+ Each directory is resolved to its owning Claude Code plugin, which
+ is registered once and whose skills are enabled by name, so the CLI
+ loads only the ``SKILL.md`` frontmatter up front and reads the body
+ on demand.
+ """
+ return True
+
async def execute(
self,
agent: AgentDef,
@@ -375,15 +493,15 @@ async def execute(
event_callback: EventCallback | None = None,
skill_directories: list[str] | None = None,
) -> AgentOutput:
- # Skill content is eager-injected by AgentExecutor for this
- # provider — ``claude-agent-sdk`` exposes no skill kwarg today.
- # If/when the upstream SDK gains one, flip
- # ``supports_native_skills`` to True and forward this arg.
- del skill_directories
-
if query is None or ClaudeAgentOptions is None:
raise ProviderError("Claude Agent SDK not available")
+ # Resolved up front so an unloadable skill fails the run rather than
+ # quietly handing the agent less than the workflow declared. Providers
+ # are constructed lazily, so this surfaces when the first agent on this
+ # provider runs, not at `conductor validate`.
+ skill_names, skill_plugins = _resolve_skill_plugins(skill_directories)
+
# Verbose / full-mode flags drive optional diagnostic output. They
# live in the CLI layer, so importing them couples this provider
# to the CLI. Wrap defensively so library users (no CLI installed)
@@ -412,7 +530,9 @@ async def execute(
else self._max_session_seconds
)
- sdk_tools, permission_mode = self._resolve_tool_config(tools, agent)
+ sdk_tools, permission_mode = self._resolve_tool_config(
+ tools, agent, skills_enabled=bool(skill_names)
+ )
# ``os.getcwd()`` raises ``OSError`` when the process cwd has been
# deleted or an ancestor lost traversal permission. Resolve it here
@@ -450,6 +570,27 @@ async def execute(
# plugin-provided servers, and permission_mode bypasses approval
# for whatever they expose. Only declared servers may attach.
strict_mcp_config=True,
+ # The skills counterpart of strict_mcp_config, and unconditional
+ # for the same reason: left unset, the CLI loads user settings
+ # (~/.claude/settings.json), project settings (.claude/settings.json)
+ # and local settings — which between them bring in ambient skills,
+ # CLAUDE.md, and hooks the workflow never declared. Setting `skills`
+ # makes this doubly load-bearing: the SDK re-defaults setting_sources
+ # to ["user", "project"] whenever `skills` is set and this is None.
+ # Conductor surfaces instruction files through its own opt-in
+ # `--workspace-instructions`; settings and hooks have no equivalent.
+ setting_sources=[],
+ # Load-bearing but invisible in argv: the SDK forwards an explicit
+ # list in the `initialize` control request (_internal/query.py), and
+ # only there does [] differ from None. None means "CLI defaults
+ # apply", [] means "enable no skills" — which is what makes
+ # `skills: []` an honest opt-out. Note this is a context filter, not
+ # a sandbox: unlisted skills are hidden from the model's listing and
+ # rejected by the Skill tool, but their files stay readable on disk.
+ skills=skill_names,
+ # Unlike `skills`, [] is already this field's default and means
+ # nothing special.
+ plugins=skill_plugins,
)
content_parts: list[str] = []
@@ -685,6 +826,8 @@ async def close(self) -> None:
def _resolve_tool_config(
tools: list[str] | None,
agent: AgentDef,
+ *,
+ skills_enabled: bool,
) -> tuple[Any, str | None]:
"""Resolve the SDK ``tools`` and ``permission_mode`` for an agent.
@@ -711,6 +854,9 @@ def _resolve_tool_config(
* ``tools`` empty and ``agent.tools == []`` — explicit "no tools"
request. Pass an empty list to the SDK so all tools are disabled.
Drop the permission bypass because there are no tools to permit.
+ When skills are enabled, grant the ``Skill`` tool back: an empty
+ base tool set would otherwise leave the declared skill unreachable,
+ silently ignoring the ``skills:`` the workflow asked for.
* ``tools`` non-empty — raise ``ProviderError``. Workflow tool
name → CLI tool ID translation is not implemented (tracked as
a follow-up). Silently dropping the allowlist would be a
@@ -722,6 +868,8 @@ def _resolve_tool_config(
agent: The agent definition. ``agent.tools`` carries the raw
omitted-vs-explicit-empty signal; ``agent.name`` is used in
the error message.
+ skills_enabled: Whether this agent has skills to load. Only
+ affects the explicit ``tools: []`` case.
Returns:
A ``(sdk_tools, permission_mode)`` tuple suitable for
@@ -737,7 +885,13 @@ def _resolve_tool_config(
if agent.tools is None:
# Omitted -> default claude_code preset (filesystem/bash/web).
return _DEFAULT_TOOL_PRESET, "bypassPermissions"
- # Explicit `tools: []` -> no tools, no permission bypass.
+ # Explicit `tools: []` -> no tools, no permission bypass. The
+ # Skill tool is the one exception, and only when skills are on:
+ # it loads declared skill content and grants nothing else. The
+ # SDK auto-allows it via `Skill()` in allowed_tools, so it
+ # does not need the permission bypass either.
+ if skills_enabled:
+ return [_SKILL_TOOL], None
return [], None
raise ProviderError(
f"Agent '{agent.name}' resolves to tools={tools!r} (declared on "
@@ -747,7 +901,8 @@ def _resolve_tool_config(
suggestion=(
"Omit both the per-agent and workflow-level 'tools:' to grant "
"the full claude_code preset, or set 'tools: []' to disable "
- "all tools."
+ "every built-in tool (bar the Skill loader when the agent "
+ "declares skills)."
),
)
diff --git a/src/conductor/skills/__init__.py b/src/conductor/skills/__init__.py
index c24408a3..0d5258ee 100644
--- a/src/conductor/skills/__init__.py
+++ b/src/conductor/skills/__init__.py
@@ -18,6 +18,11 @@
* **Copilot** — native ``skill_directories`` on the SDK session.
Skill becomes discoverable; the model loads it as relevant
(progressive disclosure, token-efficient).
+ * **Claude Agent SDK** — the Claude Code plugin owning the skill is
+ registered on the session (``--plugin-dir``) and the skill enabled
+ by its ``:`` name, also progressive. Skills the
+ workflow did not declare are filtered out of the model's listing
+ rather than inherited from the machine.
* **Claude** — eager preamble injection of ``SKILL.md`` plus
``references/*.md`` into the agent's rendered prompt. The
Anthropic API has no server-side skill surface without adopting
@@ -32,15 +37,21 @@
from conductor.skills.loader import load_skill_content
from conductor.skills.registry import (
SkillNotFoundError,
+ SkillPlugin,
+ SkillPluginError,
get_skill_directory,
list_builtin_skills,
resolve_skill_directories,
+ resolve_skill_plugin,
)
__all__ = [
"SkillNotFoundError",
+ "SkillPlugin",
+ "SkillPluginError",
"get_skill_directory",
"list_builtin_skills",
"load_skill_content",
"resolve_skill_directories",
+ "resolve_skill_plugin",
]
diff --git a/src/conductor/skills/loader.py b/src/conductor/skills/loader.py
index a02d4f32..d8455b0d 100644
--- a/src/conductor/skills/loader.py
+++ b/src/conductor/skills/loader.py
@@ -1,12 +1,13 @@
"""Load skill content for eager preamble injection (Claude-path mechanism).
-On providers that lack a native skill surface (Claude, today), Conductor
-loads the full ``SKILL.md`` plus every ``references/*.md`` file from
-each enabled skill's directory and prepends them to the agent's rendered
-prompt, wrapped in ```` tags. On providers with native
-support (Copilot's ``skill_directories``), eager injection is skipped
-and the SDK handles discovery natively — the model loads skill content
-only when relevant, which is more token-efficient.
+On providers that lack a native skill surface, Conductor loads the full
+``SKILL.md`` plus every ``references/*.md`` file from each enabled
+skill's directory and prepends them to the agent's rendered prompt,
+wrapped in ```` tags. On providers with native support
+(Copilot's ``skill_directories``, claude-agent-sdk's plugin-scoped
+``skills`` option), eager injection is skipped and the SDK handles
+discovery natively — the model loads skill content only when relevant,
+which is more token-efficient.
The loader is the *content* side of the skill abstraction. The
:mod:`conductor.skills.registry` module is the *resolution* side.
diff --git a/src/conductor/skills/registry.py b/src/conductor/skills/registry.py
index 107f8ae4..3301c624 100644
--- a/src/conductor/skills/registry.py
+++ b/src/conductor/skills/registry.py
@@ -7,7 +7,7 @@
docs.
The plugins directory is bundled as wheel package data via the
-``[tool.hatch.build.targets.wheel] artifacts`` entry in
+``[tool.hatch.build.targets.wheel.force-include]`` entries in
``pyproject.toml``. Resolution prefers a package-relative location so
installed wheels work; it falls back to a source-checkout location for
editable installs and tests.
@@ -18,7 +18,10 @@
from __future__ import annotations
+import json
import logging
+import re
+from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
@@ -29,9 +32,24 @@ class SkillNotFoundError(ValueError):
"""Raised when a skill name is not found in the registry."""
+class SkillPluginError(SkillNotFoundError):
+ """Raised when a skill's owning plugin is present but unusable.
+
+ Distinct from "this skill has no plugin at all", which
+ :func:`resolve_skill_plugin` reports by returning ``None``. Keeping
+ the two apart is what lets callers tell a user whose manifest is
+ broken from a user whose skill simply isn't packaged as a plugin.
+ """
+
+
# Built-in skills. Maps the user-facing skill name (the string that
# appears in ``skills: [...]``) to a relative path from the repository
# root / wheel root where the skill directory lives.
+#
+# The final path segment must equal the key: the claude-agent-sdk
+# provider re-derives the skill name from the directory basename, so a
+# divergence here would silently rename the skill. Pinned by
+# ``test_builtin_skill_names_match_their_directory_basenames``.
_BUILTIN_SKILLS: dict[str, str] = {
"conductor": "plugins/conductor/skills/conductor",
}
@@ -129,3 +147,208 @@ def resolve_skill_directories(skills: list[str]) -> list[Path]:
seen.add(path)
out.append(path)
return out
+
+
+# Marker file identifying a Claude Code plugin root. A skill directory
+# that lives under one can be registered natively with the
+# claude-agent-sdk via ``--plugin-dir``.
+_PLUGIN_MANIFEST: Path = Path(".claude-plugin") / "plugin.json"
+
+# Directory a plugin keeps its skills in, relative to the plugin root.
+_PLUGIN_SKILLS_DIR: str = "skills"
+
+# How far above a skill directory to look for the plugin manifest. The
+# layout is ``/skills//``, so two levels is the exact
+# distance; a third allows one level of grouping under ``skills/``.
+_PLUGIN_SEARCH_DEPTH: int = 3
+
+# Characters allowed in a plugin or skill name. The two are joined with
+# ``:`` into a qualified name, which the SDK expands to ``Skill()``
+# and joins with ``,`` into a single ``--allowedTools`` value — so a name
+# containing either delimiter would split into extra permission rules.
+_SAFE_NAME = re.compile(r"\A[A-Za-z0-9_.-]+\Z")
+
+
+@dataclass(frozen=True)
+class SkillPlugin:
+ """A skill directory together with the plugin that owns it.
+
+ Providers whose SDK loads skills through the Claude Code *plugin*
+ surface (``claude-agent-sdk``) need the plugin root and the
+ plugin-qualified skill name, not just the skill directory.
+
+ Invariants (enforced in ``__post_init__``):
+
+ * ``skill_name`` and ``plugin_name`` are non-empty and match
+ :data:`_SAFE_NAME`, so ``qualified_name`` cannot inject extra
+ entries into the SDK's delimiter-joined tool list.
+ * ``plugin_root`` is absolute.
+
+ Violations raise :class:`SkillPluginError` — a ``ValueError`` subclass,
+ so it is still the exception a value object is expected to raise, but
+ one callers can catch alongside the resolver's own failures.
+ """
+
+ skill_name: str
+ """Skill directory name.
+
+ :func:`resolve_skill_plugin` checks this against the ``name`` in the
+ skill's ``SKILL.md`` frontmatter, because the CLI resolves the
+ enabled-skill list against that name — a divergence would hide the
+ skill rather than fail.
+ """
+
+ plugin_name: str
+ """Plugin name as declared in ``.claude-plugin/plugin.json``."""
+
+ plugin_root: Path
+ """Absolute path to the plugin root (the directory holding
+ ``.claude-plugin/``), suitable for ``--plugin-dir``.
+
+ Broader than :attr:`skill_name`: registering a root exposes every
+ command, agent, skill, and hook the plugin ships. Callers must pair
+ it with :attr:`qualified_name` in the SDK's ``skills`` filter to
+ narrow back down to the declared skill.
+ """
+
+ def __post_init__(self) -> None:
+ # SkillPluginError (a ValueError subclass) rather than a bare
+ # ValueError: the provider catches that class to report the real
+ # reason, so a name the producer failed to reject must not escape
+ # as an unhandled exception.
+ for label, value in (
+ ("skill_name", self.skill_name),
+ ("plugin_name", self.plugin_name),
+ ):
+ if not _SAFE_NAME.match(value):
+ raise SkillPluginError(
+ f"SkillPlugin.{label} must match {_SAFE_NAME.pattern} "
+ f"(it is joined into a delimited tool list), got {value!r}"
+ )
+ if not self.plugin_root.is_absolute():
+ raise SkillPluginError(
+ f"SkillPlugin.plugin_root must be absolute, got {self.plugin_root!s}"
+ )
+
+ @property
+ def qualified_name(self) -> str:
+ """``:`` — how the SDK names a plugin's skill."""
+ return f"{self.plugin_name}:{self.skill_name}"
+
+
+def _read_plugin_name(manifest: Path) -> str:
+ """Read the ``name`` a plugin manifest declares.
+
+ Args:
+ manifest: Path to an existing ``.claude-plugin/plugin.json``.
+
+ Returns:
+ The declared plugin name.
+
+ Raises:
+ SkillPluginError: If the file cannot be read, is not a JSON
+ object, or declares no usable ``name``.
+ """
+ try:
+ parsed = json.loads(manifest.read_text(encoding="utf-8"))
+ except (OSError, ValueError) as exc:
+ raise SkillPluginError(f"Plugin manifest at {manifest} could not be read: {exc}") from exc
+ # Anything that parses to something other than an object (a bare
+ # array, string, or null) is as unusable as a parse failure.
+ name = parsed.get("name") if isinstance(parsed, dict) else None
+ if not isinstance(name, str) or not name:
+ raise SkillPluginError(f"Plugin manifest at {manifest} declares no usable 'name'.")
+ if not _SAFE_NAME.match(name):
+ raise SkillPluginError(
+ f"Plugin manifest at {manifest} declares name {name!r}, which contains "
+ f"characters outside {_SAFE_NAME.pattern}. The name is joined into the "
+ "CLI's delimiter-separated tool list, so it must not contain ':' or ','."
+ )
+ return name
+
+
+def _declared_skill_name(skill_dir: Path) -> str:
+ """Read the ``name`` a skill's ``SKILL.md`` frontmatter declares.
+
+ Args:
+ skill_dir: Directory expected to contain ``SKILL.md``.
+
+ Returns:
+ The declared skill name.
+
+ Raises:
+ SkillPluginError: If ``SKILL.md`` is missing, unreadable, or
+ declares no ``name`` in its frontmatter.
+ """
+ skill_md = skill_dir / "SKILL.md"
+ if not skill_md.is_file():
+ raise SkillPluginError(
+ f"Skill directory {skill_dir} has no SKILL.md, so the claude CLI will "
+ "not expose it under any name."
+ )
+ try:
+ text = skill_md.read_text(encoding="utf-8")
+ except OSError as exc:
+ raise SkillPluginError(f"Skill manifest at {skill_md} could not be read: {exc}") from exc
+ match = re.search(r"\A---\r?\n(.*?)\r?\n---", text, re.DOTALL)
+ name = re.search(r"^name:\s*(\S+)\s*$", match.group(1), re.MULTILINE) if match else None
+ if name is None:
+ raise SkillPluginError(
+ f"Skill manifest at {skill_md} declares no 'name' in its YAML frontmatter. "
+ "The claude CLI resolves enabled skills by that name."
+ )
+ return name.group(1)
+
+
+def resolve_skill_plugin(skill_dir: Path) -> SkillPlugin | None:
+ """Find the Claude Code plugin that owns a skill directory.
+
+ Walks up from ``skill_dir`` through its nearest
+ :data:`_PLUGIN_SEARCH_DEPTH` ancestors looking for a
+ ``.claude-plugin/plugin.json`` manifest. An ancestor only counts as
+ the owner when ``skill_dir`` also sits under its ``skills/``
+ directory, so an unrelated plugin further up the tree cannot adopt a
+ skill it does not ship.
+
+ Args:
+ skill_dir: Path to a skill directory (the one holding
+ ``SKILL.md``). Resolved to an absolute path.
+
+ Returns:
+ The resolved :class:`SkillPlugin`, or ``None`` when no owning
+ plugin root is found. Callers decide whether that is fatal —
+ providers that can only load skills via plugins should refuse
+ loudly rather than drop the skill silently.
+
+ Raises:
+ SkillPluginError: If an owning plugin is found but cannot be
+ used: an unreadable or nameless manifest, a missing
+ ``SKILL.md``, or a frontmatter ``name`` that disagrees with
+ the directory name. Each of these would otherwise leave the
+ agent running without the skill it declared, with nothing to
+ diagnose it by.
+ """
+ skill_dir = skill_dir.resolve()
+ for candidate in skill_dir.parents[:_PLUGIN_SEARCH_DEPTH]:
+ manifest = candidate / _PLUGIN_MANIFEST
+ if not manifest.is_file():
+ continue
+ if not skill_dir.is_relative_to(candidate / _PLUGIN_SKILLS_DIR):
+ # A plugin root that does not ship this skill. Keep walking:
+ # adopting it would register an unrelated plugin and ask the
+ # CLI for a name it cannot resolve.
+ continue
+ plugin_name = _read_plugin_name(manifest)
+ declared = _declared_skill_name(skill_dir)
+ if declared != skill_dir.name:
+ raise SkillPluginError(
+ f"Skill at {skill_dir} declares 'name: {declared}' in SKILL.md but lives "
+ f"in a directory named {skill_dir.name!r}. The CLI would be asked for "
+ f"'{plugin_name}:{skill_dir.name}', which matches nothing."
+ )
+ return SkillPlugin(
+ skill_name=skill_dir.name,
+ plugin_name=plugin_name,
+ plugin_root=candidate,
+ )
+ return None
diff --git a/tests/test_providers/test_claude_agent_sdk.py b/tests/test_providers/test_claude_agent_sdk.py
index 93507374..3494db18 100644
--- a/tests/test_providers/test_claude_agent_sdk.py
+++ b/tests/test_providers/test_claude_agent_sdk.py
@@ -35,6 +35,7 @@
from conductor.providers.claude_agent_sdk import ( # noqa: E402
ClaudeAgentSdkProvider,
_remove_mcp_config,
+ _resolve_skill_plugins,
_translate_mcp_servers,
_write_mcp_config,
)
@@ -2301,3 +2302,207 @@ def test_genuine_connection_drop_stays_retryable(self) -> None:
exc = CLIConnectionError("subprocess died unexpectedly")
assert "firewall" in _classify_error_suggestion(exc)
assert _is_retryable_exception(exc) is True
+
+
+class TestSkillsWiring:
+ """Skills reach the SDK natively, and ambient skills never do.
+
+ The provider's contract is ultimately the ``claude`` CLI command line,
+ so these assert the argv the SDK builds from our options rather than
+ stopping at the options object.
+ """
+
+ @staticmethod
+ async def _capture_options(
+ agent: AgentDef,
+ skill_directories: list[str] | None = None,
+ tools: list[str] | None = None,
+ ):
+ captured: dict = {}
+
+ async def fake_query(**kwargs):
+ captured["options"] = kwargs["options"]
+ yield _result(result="ok")
+
+ with patch("conductor.providers.claude_agent_sdk.query", fake_query):
+ provider = ClaudeAgentSdkProvider()
+ await provider.execute(
+ agent=agent,
+ context={},
+ rendered_prompt="hi",
+ tools=tools,
+ skill_directories=skill_directories,
+ )
+ return captured["options"]
+
+ @staticmethod
+ def _argv(options) -> list[str]:
+ """Build the real CLI command the SDK would spawn for these options."""
+ from claude_agent_sdk._internal.transport.subprocess_cli import (
+ SubprocessCLITransport,
+ )
+
+ transport = SubprocessCLITransport(prompt="hi", options=options)
+ transport._cli_path = "/usr/bin/claude"
+ return transport._build_command()
+
+ @staticmethod
+ def _skill_dirs() -> list[str]:
+ from conductor.skills import resolve_skill_directories
+
+ return [str(p) for p in resolve_skill_directories(["conductor"])]
+
+ @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True)
+ async def test_declared_skill_is_enabled_via_plugin(self) -> None:
+ skill_dir = Path(self._skill_dirs()[0])
+ options = await self._capture_options(
+ AgentDef(name="t", prompt="hi"), skill_directories=[str(skill_dir)]
+ )
+
+ assert options.skills == ["conductor:conductor"]
+ assert options.plugins == [{"type": "local", "path": str(skill_dir.parents[1])}]
+
+ argv = self._argv(options)
+ assert "--plugin-dir" in argv
+ assert argv[argv.index("--plugin-dir") + 1] == str(skill_dir.parents[1])
+ assert argv[argv.index("--allowedTools") + 1] == "Skill(conductor:conductor)"
+
+ @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True)
+ async def test_no_skills_suppresses_ambient_discovery(self) -> None:
+ """``skills=[]`` is not ``skills=None``: None would let CLI defaults win."""
+ options = await self._capture_options(AgentDef(name="t", prompt="hi"))
+
+ assert options.skills == []
+ assert options.plugins == []
+
+ argv = self._argv(options)
+ assert "--plugin-dir" not in argv
+ assert "--allowedTools" not in argv
+
+ @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True)
+ @pytest.mark.parametrize("skills", [None, "declared"])
+ async def test_setting_sources_isolated_unconditionally(self, skills: str | None) -> None:
+ """No ambient skills, CLAUDE.md, settings.json, or hooks — ever."""
+ options = await self._capture_options(
+ AgentDef(name="t", prompt="hi"),
+ skill_directories=self._skill_dirs() if skills else None,
+ )
+
+ assert options.setting_sources == []
+ # The SDK only defaults setting_sources to ["user", "project"] when
+ # it is None, so an explicit [] has to survive into the argv.
+ assert "--setting-sources=" in self._argv(options)
+
+ @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True)
+ async def test_explicit_no_tools_still_grants_skill_tool(self) -> None:
+ """``tools: []`` + skills must not leave the skill unreachable."""
+ options = await self._capture_options(
+ AgentDef(name="t", prompt="hi", tools=[]),
+ skill_directories=self._skill_dirs(),
+ tools=[],
+ )
+
+ assert options.tools == ["Skill"]
+ argv = self._argv(options)
+ assert argv[argv.index("--tools") + 1] == "Skill"
+ # With no permission bypass, the SDK's allowed_tools injection is the
+ # only thing permitting the tool -- assert it here, not just on the
+ # preset path where bypassPermissions would mask its absence.
+ assert argv[argv.index("--allowedTools") + 1] == "Skill(conductor:conductor)"
+ assert options.permission_mode is None
+
+ @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True)
+ async def test_explicit_no_tools_without_skills_stays_empty(self) -> None:
+ options = await self._capture_options(AgentDef(name="t", prompt="hi", tools=[]), tools=[])
+
+ assert options.tools == []
+ argv = self._argv(options)
+ assert argv[argv.index("--tools") + 1] == ""
+
+ @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True)
+ async def test_omitted_tools_keeps_preset_with_skills(self) -> None:
+ options = await self._capture_options(
+ AgentDef(name="t", prompt="hi"), skill_directories=self._skill_dirs()
+ )
+
+ assert options.tools == {"type": "preset", "preset": "claude_code"}
+ argv = self._argv(options)
+ assert argv[argv.index("--tools") + 1] == "default"
+
+ @staticmethod
+ def _make_plugin(root: Path, plugin_name: str, *skills: str) -> Path:
+ (root / ".claude-plugin").mkdir(parents=True)
+ (root / ".claude-plugin" / "plugin.json").write_text(f'{{"name": "{plugin_name}"}}')
+ for skill in skills:
+ (root / "skills" / skill).mkdir(parents=True)
+ (root / "skills" / skill / "SKILL.md").write_text(f"---\nname: {skill}\n---\n")
+ return root
+
+ @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True)
+ async def test_skill_outside_a_plugin_is_refused(self, tmp_path: Path) -> None:
+ orphan = tmp_path / "skills" / "lonely"
+ orphan.mkdir(parents=True)
+ (orphan / "SKILL.md").write_text("---\nname: lonely\n---\n")
+
+ with pytest.raises(ProviderError, match="not part of a Claude Code plugin"):
+ await self._capture_options(
+ AgentDef(name="t", prompt="hi"), skill_directories=[str(orphan)]
+ )
+
+ @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True)
+ async def test_broken_plugin_manifest_is_refused_with_its_reason(self, tmp_path: Path) -> None:
+ """A present-but-broken manifest must not be reported as an absent one."""
+ root = tmp_path / "plug"
+ (root / ".claude-plugin").mkdir(parents=True)
+ (root / ".claude-plugin" / "plugin.json").write_text("{not json")
+ skill = root / "skills" / "s"
+ skill.mkdir(parents=True)
+ (skill / "SKILL.md").write_text("---\nname: s\n---\n")
+
+ with pytest.raises(ProviderError, match="cannot be loaded") as exc:
+ await self._capture_options(
+ AgentDef(name="t", prompt="hi"), skill_directories=[str(skill)]
+ )
+ assert "could not be read" in str(exc.value)
+ assert exc.value.is_retryable is False
+
+ @patch("conductor.providers.claude_agent_sdk.CLAUDE_AGENT_SDK_AVAILABLE", True)
+ async def test_missing_plugin_error_is_not_retryable(self, tmp_path: Path) -> None:
+ """A path containing 'connection' must not trip the retryability
+ heuristic, which sniffs the message text."""
+ orphan = tmp_path / "connection-hub" / "skills" / "lonely"
+ orphan.mkdir(parents=True)
+ (orphan / "SKILL.md").write_text("---\nname: lonely\n---\n")
+
+ with pytest.raises(ProviderError) as exc:
+ await self._capture_options(
+ AgentDef(name="t", prompt="hi"), skill_directories=[str(orphan)]
+ )
+ assert exc.value.is_retryable is False
+
+ def test_two_skills_from_one_plugin_register_it_once(self, tmp_path: Path) -> None:
+ """Roots dedupe, names do not -- dropping a name would under-serve the
+ workflow."""
+ root = self._make_plugin(tmp_path / "plug", "p", "alpha", "beta")
+ names, plugins = _resolve_skill_plugins(
+ [str(root / "skills" / "alpha"), str(root / "skills" / "beta")]
+ )
+
+ assert names == ["p:alpha", "p:beta"]
+ assert plugins == [{"type": "local", "path": str(root)}]
+
+ def test_duplicate_skill_directory_is_collapsed(self) -> None:
+ skill_dir = self._skill_dirs()[0]
+ names, plugins = _resolve_skill_plugins([skill_dir, skill_dir])
+
+ assert names == ["conductor:conductor"]
+ assert len(plugins) == 1
+
+ def test_two_plugins_claiming_one_name_are_refused(self, tmp_path: Path) -> None:
+ """Deduping the clash away would silently drop one declared skill."""
+ a = self._make_plugin(tmp_path / "vendorA", "dup", "s")
+ b = self._make_plugin(tmp_path / "vendorB", "dup", "s")
+
+ with pytest.raises(ProviderError, match="Two different plugins") as exc:
+ _resolve_skill_plugins([str(a / "skills" / "s"), str(b / "skills" / "s")])
+ assert exc.value.is_retryable is False
diff --git a/tests/test_skills/test_executor_integration.py b/tests/test_skills/test_executor_integration.py
index 62fdc0d5..60c07ca6 100644
--- a/tests/test_skills/test_executor_integration.py
+++ b/tests/test_skills/test_executor_integration.py
@@ -13,10 +13,13 @@
import asyncio
from typing import Any
+import pytest
+
from conductor.config.schema import AgentDef
from conductor.executor.agent import AgentExecutor
from conductor.providers.base import AgentOutput, AgentProvider, EventCallback
from conductor.providers.copilot import CopilotProvider
+from conductor.skills import get_skill_directory
from conductor.skills.loader import _cached_skill_payload
@@ -29,6 +32,8 @@ class _StubNonNativeProvider(AgentProvider, abstract=True):
providers — this is a test fake, not a real provider.
"""
+ captured: list[str] | None = None
+
@property
def supports_native_skills(self) -> bool:
return False
@@ -43,7 +48,8 @@ async def execute(
event_callback: EventCallback | None = None,
skill_directories: list[str] | None = None,
) -> AgentOutput:
- return AgentOutput(content={"echo": rendered_prompt})
+ self.captured = skill_directories
+ return AgentOutput(content={"echo": rendered_prompt}, raw_response=rendered_prompt)
async def validate_connection(self) -> bool:
return True
@@ -71,6 +77,105 @@ def test_provider_advertises_native_support(self) -> None:
assert CopilotProvider().supports_native_skills is True
+class _CapturingNativeProvider(AgentProvider, abstract=True):
+ """Native-skill provider stub that records what the executor forwards.
+
+ The negative "no in the prompt" assertions cannot tell a
+ working native path from one that dropped the skills entirely, so this
+ captures the positive side.
+ """
+
+ @property
+ def supports_native_skills(self) -> bool:
+ return True
+
+ def __init__(self) -> None:
+ self.captured: list[str] | None = None
+
+ async def execute(
+ self,
+ agent: AgentDef,
+ context: dict[str, Any],
+ rendered_prompt: str,
+ tools: list[str] | None = None,
+ interrupt_signal: asyncio.Event | None = None,
+ event_callback: EventCallback | None = None,
+ skill_directories: list[str] | None = None,
+ ) -> AgentOutput:
+ self.captured = skill_directories
+ return AgentOutput(content={"ok": True}, raw_response="ok")
+
+ async def validate_connection(self) -> bool:
+ return True
+
+ async def close(self) -> None:
+ return None
+
+
+class TestSkillDirectoriesReachTheProvider:
+ """The executor -> provider seam that native skill loading rides on.
+
+ Without these, `skill_directories=None` (or a dropped
+ `supports_native_skills` check) suppresses every skill while the whole
+ suite stays green -- the exact silent-drop failure #352 was about.
+ """
+
+ def setup_method(self) -> None:
+ _cached_skill_payload.cache_clear()
+
+ @staticmethod
+ def _run(provider: _CapturingNativeProvider, agent: AgentDef) -> None:
+ executor = AgentExecutor(provider, workflow_skills=["conductor"])
+ asyncio.run(executor.execute(agent, {}))
+
+ def test_workflow_default_reaches_provider(self) -> None:
+ provider = _CapturingNativeProvider()
+ self._run(provider, AgentDef(name="a", model="m", prompt="p"))
+ assert provider.captured == [str(get_skill_directory("conductor"))]
+
+ def test_agent_list_reaches_provider(self) -> None:
+ provider = _CapturingNativeProvider()
+ executor = AgentExecutor(provider, workflow_skills=[])
+ agent = AgentDef(name="a", model="m", prompt="p", skills=["conductor"])
+ asyncio.run(executor.execute(agent, {}))
+ assert provider.captured == [str(get_skill_directory("conductor"))]
+
+ def test_agent_opt_out_reaches_provider_as_no_dirs(self) -> None:
+ provider = _CapturingNativeProvider()
+ self._run(provider, AgentDef(name="a", model="m", prompt="p", skills=[]))
+ assert not provider.captured
+
+ def test_non_native_provider_gets_no_directories(self) -> None:
+ """Forwarding to a non-native provider would double-load the skill on
+ top of the eager injection it already received."""
+ provider = _StubNonNativeProvider()
+ executor = AgentExecutor(provider, workflow_skills=["conductor"])
+ asyncio.run(executor.execute(AgentDef(name="a", model="m", prompt="p"), {}))
+ assert provider.captured is None
+
+
+class TestClaudeAgentSdkNativeSkills:
+ """claude-agent-sdk loads skills through the SDK, not the prompt."""
+
+ def setup_method(self) -> None:
+ pytest.importorskip("claude_agent_sdk", reason="claude-agent-sdk extra not installed")
+
+ def test_no_skill_content_in_rendered_prompt(self) -> None:
+ from conductor.providers.claude_agent_sdk import ClaudeAgentSdkProvider
+
+ executor = AgentExecutor(ClaudeAgentSdkProvider(), workflow_skills=["conductor"])
+ agent = AgentDef(name="a", model="claude-sonnet-4-5", prompt="Hello world")
+ rendered = executor.render_prompt(agent, {})
+ assert "" not in rendered
+ assert '' not in rendered
+ assert "Hello world" in rendered
+
+ def test_provider_advertises_native_support(self) -> None:
+ from conductor.providers.claude_agent_sdk import ClaudeAgentSdkProvider
+
+ assert ClaudeAgentSdkProvider().supports_native_skills is True
+
+
class TestNonNativeProviderEagerInjection:
"""Non-native providers receive skill content via the rendered prompt."""
diff --git a/tests/test_skills/test_registry.py b/tests/test_skills/test_registry.py
index 5709866b..0a14eca0 100644
--- a/tests/test_skills/test_registry.py
+++ b/tests/test_skills/test_registry.py
@@ -2,16 +2,26 @@
from __future__ import annotations
+import subprocess
+import zipfile
from pathlib import Path
import pytest
from conductor.skills import (
SkillNotFoundError,
+ SkillPlugin,
+ SkillPluginError,
get_skill_directory,
list_builtin_skills,
resolve_skill_directories,
+ resolve_skill_plugin,
)
+from conductor.skills.registry import _BUILTIN_SKILLS
+
+
+def _repo_root() -> Path:
+ return Path(__file__).resolve().parents[2]
class TestListBuiltinSkills:
@@ -60,3 +70,183 @@ def test_deduplicates(self) -> None:
def test_unknown_raises(self) -> None:
with pytest.raises(SkillNotFoundError):
resolve_skill_directories(["conductor", "nope"])
+
+
+def _make_plugin(
+ tmp_path: Path,
+ *,
+ manifest: str = '{"name": "p"}',
+ skill: str = "s",
+ frontmatter_name: str | None = "s",
+ nest: str = "",
+) -> Path:
+ """Build a throwaway plugin tree and return its skill directory."""
+ root = tmp_path / "plug"
+ (root / ".claude-plugin").mkdir(parents=True)
+ (root / ".claude-plugin" / "plugin.json").write_text(manifest)
+ skill_dir = root / "skills" / nest / skill if nest else root / "skills" / skill
+ skill_dir.mkdir(parents=True)
+ if frontmatter_name is not None:
+ (skill_dir / "SKILL.md").write_text(f"---\nname: {frontmatter_name}\n---\n")
+ return skill_dir
+
+
+class TestResolveSkillPlugin:
+ """Built-in skills ship inside a Claude Code plugin, which is how the
+ claude-agent-sdk provider loads them (``--plugin-dir`` + qualified name)."""
+
+ def test_builtin_skill_resolves_to_its_plugin(self) -> None:
+ plugin = resolve_skill_plugin(get_skill_directory("conductor"))
+ assert plugin is not None
+ assert plugin.skill_name == "conductor"
+ assert plugin.plugin_name == "conductor"
+ assert plugin.qualified_name == "conductor:conductor"
+
+ def test_plugin_root_holds_the_manifest(self) -> None:
+ plugin = resolve_skill_plugin(get_skill_directory("conductor"))
+ assert plugin is not None
+ assert (plugin.plugin_root / ".claude-plugin" / "plugin.json").is_file()
+
+ @pytest.mark.parametrize("name", list_builtin_skills())
+ def test_frontmatter_name_matches_directory_name(self, name: str) -> None:
+ """The CLI enables skills by frontmatter name while the qualified name
+ uses the directory name; drift silently loads no skill."""
+ skill_dir = get_skill_directory(name)
+ frontmatter = (skill_dir / "SKILL.md").read_text().split("---")[1]
+ assert f"name: {skill_dir.name}\n" in frontmatter
+
+ def test_builtin_names_match_their_directory_basenames(self) -> None:
+ """``skill_name`` is re-derived from the basename, so a registry key
+ that diverges from it would silently rename the skill."""
+ for name, rel in _BUILTIN_SKILLS.items():
+ assert Path(rel).name == name
+
+ def test_manifest_is_packaged_for_wheel_installs(self) -> None:
+ """The manifest must ship alongside the skill body, or no plugin root
+ resolves and every skills-enabled agent fails."""
+ import tomllib
+
+ with (_repo_root() / "pyproject.toml").open("rb") as handle:
+ pyproject = tomllib.load(handle)
+ included = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["force-include"]
+ assert "plugins/conductor/.claude-plugin" in included
+
+ def test_manifest_lands_in_the_built_wheel(self, tmp_path: Path) -> None:
+ """The force-include entry is only worth as much as the artifact it
+ produces — a stray exclude pattern would leave the string in place and
+ the manifest out of the wheel."""
+ subprocess.run(
+ ["uv", "build", "--wheel", "--out-dir", str(tmp_path)],
+ cwd=_repo_root(),
+ check=True,
+ capture_output=True,
+ )
+ names = zipfile.ZipFile(next(tmp_path.glob("*.whl"))).namelist()
+ assert "plugins/conductor/.claude-plugin/plugin.json" in names
+ assert "plugins/conductor/skills/conductor/SKILL.md" in names
+
+ def test_directory_outside_a_plugin_returns_none(self, tmp_path: Path) -> None:
+ orphan = tmp_path / "skills" / "lonely"
+ orphan.mkdir(parents=True)
+ assert resolve_skill_plugin(orphan) is None
+
+ def test_manifest_beyond_search_depth_is_ignored(self, tmp_path: Path) -> None:
+ """An unbounded walk would let a distant ancestor adopt the skill."""
+ skill = _make_plugin(tmp_path, nest="a/b", skill="s", frontmatter_name="s")
+ assert resolve_skill_plugin(skill) is None
+
+ def test_plugin_that_does_not_ship_the_skill_is_skipped(self, tmp_path: Path) -> None:
+ """A manifest above a skill does not make that plugin its owner."""
+ root = tmp_path / "plug"
+ (root / ".claude-plugin").mkdir(parents=True)
+ (root / ".claude-plugin" / "plugin.json").write_text('{"name": "unrelated"}')
+ stray = root / "elsewhere" / "mySkill"
+ stray.mkdir(parents=True)
+ (stray / "SKILL.md").write_text("---\nname: mySkill\n---\n")
+ assert resolve_skill_plugin(stray) is None
+
+ @pytest.mark.parametrize(
+ "manifest",
+ [
+ pytest.param("{not json", id="invalid-json"),
+ pytest.param('{"version": "1.0.0"}', id="no-name"),
+ pytest.param('{"name": 7}', id="non-string-name"),
+ pytest.param('{"name": ""}', id="empty-name"),
+ pytest.param("[1, 2, 3]", id="json-array"),
+ pytest.param("null", id="json-null"),
+ pytest.param('"a string"', id="json-string"),
+ pytest.param("42", id="json-number"),
+ ],
+ )
+ def test_unusable_manifest_raises(self, tmp_path: Path, manifest: str) -> None:
+ """Anything but an object with a usable 'name' is unusable. Raising
+ rather than returning None keeps the real reason reachable."""
+ skill = _make_plugin(tmp_path, manifest=manifest)
+ with pytest.raises(SkillPluginError):
+ resolve_skill_plugin(skill)
+
+ @pytest.mark.parametrize("name", ["evil,Bash", "a:b", "has space", "paren)"])
+ def test_unsafe_plugin_name_raises(self, tmp_path: Path, name: str) -> None:
+ """Names are joined into a delimited --allowedTools value, so a comma
+ or colon would split into extra permission rules."""
+ skill = _make_plugin(tmp_path, manifest=f'{{"name": "{name}"}}')
+ with pytest.raises(SkillPluginError, match="outside"):
+ resolve_skill_plugin(skill)
+
+ def test_missing_skill_md_raises(self, tmp_path: Path) -> None:
+ skill = _make_plugin(tmp_path, frontmatter_name=None)
+ with pytest.raises(SkillPluginError, match="no SKILL.md"):
+ resolve_skill_plugin(skill)
+
+ def test_frontmatter_without_name_raises(self, tmp_path: Path) -> None:
+ skill = _make_plugin(tmp_path, frontmatter_name=None)
+ (skill / "SKILL.md").write_text("---\ndescription: no name here\n---\n")
+ with pytest.raises(SkillPluginError, match="no 'name'"):
+ resolve_skill_plugin(skill)
+
+ def test_frontmatter_name_disagreeing_with_directory_raises(self, tmp_path: Path) -> None:
+ """The CLI resolves by frontmatter name; a mismatch would hide the
+ skill instead of failing."""
+ skill = _make_plugin(tmp_path, skill="dir-name", frontmatter_name="other-name")
+ with pytest.raises(SkillPluginError, match="matches nothing"):
+ resolve_skill_plugin(skill)
+
+ def test_relative_path_is_resolved(self, tmp_path: Path, monkeypatch) -> None:
+ skill = _make_plugin(tmp_path)
+ monkeypatch.chdir(skill.parent)
+ plugin = resolve_skill_plugin(Path("s"))
+ assert plugin is not None
+ assert plugin.plugin_root.is_absolute()
+
+
+class TestSkillPluginInvariants:
+ """The type is exported, so it guards itself rather than trusting its
+ producer."""
+
+ @pytest.mark.parametrize("bad", ["", "a,b", "a:b", "has space"])
+ def test_unsafe_names_rejected(self, bad: str) -> None:
+ with pytest.raises(SkillPluginError, match="must match"):
+ SkillPlugin(skill_name=bad, plugin_name="p", plugin_root=Path("/plug"))
+ with pytest.raises(SkillPluginError, match="must match"):
+ SkillPlugin(skill_name="s", plugin_name=bad, plugin_root=Path("/plug"))
+
+ def test_relative_plugin_root_rejected(self) -> None:
+ with pytest.raises(SkillPluginError, match="must be absolute"):
+ SkillPlugin(skill_name="s", plugin_name="p", plugin_root=Path("relative"))
+
+ def test_invariant_failures_are_skill_plugin_errors(self) -> None:
+ """The provider catches SkillPluginError to report the real reason; a
+ bare ValueError here would escape as an unhandled exception."""
+ with pytest.raises(SkillPluginError):
+ SkillPlugin(skill_name="bad!name", plugin_name="p", plugin_root=Path("/plug"))
+
+ def test_unsafe_directory_name_surfaces_as_skill_plugin_error(self, tmp_path: Path) -> None:
+ """The directory basename becomes the skill name, and nothing upstream
+ constrains it -- the type is the last line of defence."""
+ skill = _make_plugin(tmp_path, skill="bad!name", frontmatter_name="bad!name")
+ with pytest.raises(SkillPluginError, match="must match"):
+ resolve_skill_plugin(skill)
+
+ def test_valid_instance_builds_qualified_name(self) -> None:
+ plugin = SkillPlugin(skill_name="s", plugin_name="p", plugin_root=Path("/plug"))
+ assert plugin.qualified_name == "p:s"