diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md
index c1d01ba32..22a81859a 100644
--- a/docs/features/custom-agents.md
+++ b/docs/features/custom-agents.md
@@ -216,6 +216,7 @@ await using var session = await client.CreateSessionAsync(new SessionConfig
| `prompt` | `string` | ✅ | System prompt for the agent |
| `mcpServers` | `object` | | MCP server configurations specific to this agent |
| `infer` | `boolean` | | Whether the runtime can auto-select this agent (default: `true`) |
+| `skills` | `string[]` | | Skill names to preload into the agent's context at startup |
> **Tip:** A good `description` helps the runtime match user intent to the right agent. Be specific about the agent's expertise and capabilities.
@@ -225,6 +226,33 @@ In addition to per-agent configuration above, you can set `agent` on the **sessi
|-------------------------|------|-------------|
| `agent` | `string` | Name of the custom agent to pre-select at session creation. Must match a `name` in `customAgents`. |
+## Per-Agent Skills
+
+You can preload skills into an agent's context using the `skills` property. When specified, the **full content** of each listed skill is eagerly injected into the agent's context at startup — the agent doesn't need to invoke a skill tool; the instructions are already present. Skills are **opt-in**: agents receive no skills by default, and sub-agents do not inherit skills from the parent. Skill names are resolved from the session-level `skillDirectories`.
+
+```typescript
+const session = await client.createSession({
+ skillDirectories: ["./skills"],
+ customAgents: [
+ {
+ name: "security-auditor",
+ description: "Security-focused code reviewer",
+ prompt: "Focus on OWASP Top 10 vulnerabilities",
+ skills: ["security-scan", "dependency-check"],
+ },
+ {
+ name: "docs-writer",
+ description: "Technical documentation writer",
+ prompt: "Write clear, concise documentation",
+ skills: ["markdown-lint"],
+ },
+ ],
+ onPermissionRequest: async () => ({ kind: "approved" }),
+});
+```
+
+In this example, `security-auditor` starts with `security-scan` and `dependency-check` already injected into its context, while `docs-writer` starts with `markdown-lint`. An agent without a `skills` field receives no skill content.
+
## Selecting an Agent at Session Creation
You can pass `agent` in the session config to pre-select which custom agent should be active when the session starts. The value must match the `name` of one of the agents defined in `customAgents`.
diff --git a/docs/features/skills.md b/docs/features/skills.md
index 3bc9294aa..5f9940762 100644
--- a/docs/features/skills.md
+++ b/docs/features/skills.md
@@ -316,20 +316,23 @@ The markdown body contains the instructions that are injected into the session c
### Skills + Custom Agents
-Skills work alongside custom agents:
+Skills listed in an agent's `skills` field are **eagerly preloaded** — their full content is injected into the agent's context at startup, so the agent has access to the skill instructions immediately without needing to invoke a skill tool. Skill names are resolved from the session-level `skillDirectories`.
```typescript
const session = await client.createSession({
- skillDirectories: ["./skills/security"],
+ skillDirectories: ["./skills"],
customAgents: [{
name: "security-auditor",
description: "Security-focused code reviewer",
prompt: "Focus on OWASP Top 10 vulnerabilities",
+ skills: ["security-scan", "dependency-check"],
}],
onPermissionRequest: async () => ({ kind: "approved" }),
});
```
+> **Note:** Skills are opt-in — when `skills` is omitted, no skill content is injected. Sub-agents do not inherit skills from the parent; you must list them explicitly per agent.
+
### Skills + MCP Servers
Skills can complement MCP server capabilities:
diff --git a/docs/features/streaming-events.md b/docs/features/streaming-events.md
index d03ed95fa..cceb34c2e 100644
--- a/docs/features/streaming-events.md
+++ b/docs/features/streaming-events.md
@@ -618,6 +618,7 @@ A skill was activated for the current conversation.
| `allowedTools` | `string[]` | | Tools auto-approved while this skill is active |
| `pluginName` | `string` | | Plugin the skill originated from |
| `pluginVersion` | `string` | | Plugin version |
+| `agentName` | `string` | | Name of the agent that invoked the skill, when invoked by a custom agent |
---
diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs
index 80410c27a..f23add528 100644
--- a/dotnet/src/Types.cs
+++ b/dotnet/src/Types.cs
@@ -1515,6 +1515,16 @@ public class CustomAgentConfig
///
[JsonPropertyName("infer")]
public bool? Infer { get; set; }
+
+ ///
+ /// List of skill names to preload into this agent's context.
+ /// When set, the full content of each listed skill is eagerly injected into
+ /// the agent's context at startup. Skills are resolved by name from the
+ /// session's configured skill directories (skillDirectories).
+ /// When omitted, no skills are injected (opt-in model).
+ ///
+ [JsonPropertyName("skills")]
+ public List? Skills { get; set; }
}
///
diff --git a/dotnet/test/SkillsTests.cs b/dotnet/test/SkillsTests.cs
index d68eed79d..6082549b3 100644
--- a/dotnet/test/SkillsTests.cs
+++ b/dotnet/test/SkillsTests.cs
@@ -87,6 +87,67 @@ public async Task Should_Not_Apply_Skill_When_Disabled_Via_DisabledSkills()
await session.DisposeAsync();
}
+ [Fact]
+ public async Task Should_Allow_Agent_With_Skills_To_Invoke_Skill()
+ {
+ var skillsDir = CreateSkillDir();
+ var customAgents = new List
+ {
+ new CustomAgentConfig
+ {
+ Name = "skill-agent",
+ Description = "An agent with access to test-skill",
+ Prompt = "You are a helpful test agent.",
+ Skills = ["test-skill"]
+ }
+ };
+
+ var session = await CreateSessionAsync(new SessionConfig
+ {
+ SkillDirectories = [skillsDir],
+ CustomAgents = customAgents
+ });
+
+ Assert.Matches(@"^[a-f0-9-]+$", session.SessionId);
+
+ // The agent has Skills = ["test-skill"], so the skill content is preloaded into its context
+ var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." });
+ Assert.NotNull(message);
+ Assert.Contains(SkillMarker, message!.Data.Content);
+
+ await session.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task Should_Not_Provide_Skills_To_Agent_Without_Skills_Field()
+ {
+ var skillsDir = CreateSkillDir();
+ var customAgents = new List
+ {
+ new CustomAgentConfig
+ {
+ Name = "no-skill-agent",
+ Description = "An agent without skills access",
+ Prompt = "You are a helpful test agent."
+ }
+ };
+
+ var session = await CreateSessionAsync(new SessionConfig
+ {
+ SkillDirectories = [skillsDir],
+ CustomAgents = customAgents
+ });
+
+ Assert.Matches(@"^[a-f0-9-]+$", session.SessionId);
+
+ // The agent has no Skills field, so no skill content is injected
+ var message = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hello briefly using the test skill." });
+ Assert.NotNull(message);
+ Assert.DoesNotContain(SkillMarker, message!.Data.Content);
+
+ await session.DisposeAsync();
+ }
+
[Fact(Skip = "See the big comment around the equivalent test in the Node SDK. Skipped because the feature doesn't work correctly yet.")]
public async Task Should_Apply_Skill_On_Session_Resume_With_SkillDirectories()
{
diff --git a/go/internal/e2e/skills_test.go b/go/internal/e2e/skills_test.go
index 524280fd8..e42d4c823 100644
--- a/go/internal/e2e/skills_test.go
+++ b/go/internal/e2e/skills_test.go
@@ -108,6 +108,81 @@ func TestSkills(t *testing.T) {
session.Disconnect()
})
+ t.Run("should allow agent with skills to invoke skill", func(t *testing.T) {
+ ctx.ConfigureForTest(t)
+ cleanSkillsDir(t, ctx.WorkDir)
+ skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker)
+
+ customAgents := []copilot.CustomAgentConfig{
+ {
+ Name: "skill-agent",
+ Description: "An agent with access to test-skill",
+ Prompt: "You are a helpful test agent.",
+ Skills: []string{"test-skill"},
+ },
+ }
+
+ session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
+ OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
+ SkillDirectories: []string{skillsDir},
+ CustomAgents: customAgents,
+ })
+ if err != nil {
+ t.Fatalf("Failed to create session: %v", err)
+ }
+
+ // The agent has Skills: ["test-skill"], so the skill content is preloaded into its context
+ message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{
+ Prompt: "Say hello briefly using the test skill.",
+ })
+ if err != nil {
+ t.Fatalf("Failed to send message: %v", err)
+ }
+
+ if message.Data.Content == nil || !strings.Contains(*message.Data.Content, skillMarker) {
+ t.Errorf("Expected message to contain skill marker '%s', got: %v", skillMarker, message.Data.Content)
+ }
+
+ session.Disconnect()
+ })
+
+ t.Run("should not provide skills to agent without skills field", func(t *testing.T) {
+ ctx.ConfigureForTest(t)
+ cleanSkillsDir(t, ctx.WorkDir)
+ skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker)
+
+ customAgents := []copilot.CustomAgentConfig{
+ {
+ Name: "no-skill-agent",
+ Description: "An agent without skills access",
+ Prompt: "You are a helpful test agent.",
+ },
+ }
+
+ session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
+ OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
+ SkillDirectories: []string{skillsDir},
+ CustomAgents: customAgents,
+ })
+ if err != nil {
+ t.Fatalf("Failed to create session: %v", err)
+ }
+
+ // The agent has no Skills field, so no skill content is injected
+ message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{
+ Prompt: "Say hello briefly using the test skill.",
+ })
+ if err != nil {
+ t.Fatalf("Failed to send message: %v", err)
+ }
+
+ if message.Data.Content != nil && strings.Contains(*message.Data.Content, skillMarker) {
+ t.Errorf("Expected message to NOT contain skill marker '%s' when agent has no skills, got: %v", skillMarker, *message.Data.Content)
+ }
+
+ session.Disconnect()
+ })
+
t.Run("should apply skill on session resume with skillDirectories", func(t *testing.T) {
t.Skip("See the big comment around the equivalent test in the Node SDK. Skipped because the feature doesn't work correctly yet.")
ctx.ConfigureForTest(t)
diff --git a/go/types.go b/go/types.go
index 9f23dcb85..ececf8592 100644
--- a/go/types.go
+++ b/go/types.go
@@ -416,6 +416,8 @@ type CustomAgentConfig struct {
MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"`
// Infer indicates whether the agent should be available for model inference
Infer *bool `json:"infer,omitempty"`
+ // Skills is the list of skill names to preload into this agent's context at startup (opt-in; omit for none)
+ Skills []string `json:"skills,omitempty"`
}
// InfiniteSessionConfig configures infinite sessions with automatic context compaction
diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json
index 1af6e76c6..71fc0068b 100644
--- a/nodejs/package-lock.json
+++ b/nodejs/package-lock.json
@@ -1260,6 +1260,7 @@
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -1299,6 +1300,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -1627,6 +1629,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1954,6 +1957,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -2862,6 +2866,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -3280,6 +3285,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -3313,6 +3319,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3380,6 +3387,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts
index c20bf00db..3bcba8d6f 100644
--- a/nodejs/src/types.ts
+++ b/nodejs/src/types.ts
@@ -1006,6 +1006,14 @@ export interface CustomAgentConfig {
* @default true
*/
infer?: boolean;
+ /**
+ * List of skill names to preload into this agent's context.
+ * When set, the full content of each listed skill is eagerly injected into
+ * the agent's context at startup. Skills are resolved by name from the
+ * session's configured skill directories (`skillDirectories`).
+ * When omitted, no skills are injected (opt-in model).
+ */
+ skills?: string[];
}
/**
diff --git a/nodejs/test/e2e/skills.test.ts b/nodejs/test/e2e/skills.test.ts
index a2173648f..5683ea062 100644
--- a/nodejs/test/e2e/skills.test.ts
+++ b/nodejs/test/e2e/skills.test.ts
@@ -5,6 +5,7 @@
import * as fs from "fs";
import * as path from "path";
import { beforeEach, describe, expect, it } from "vitest";
+import type { CustomAgentConfig } from "../../src/index.js";
import { approveAll } from "../../src/index.js";
import { createSdkTestContext } from "./harness/sdkTestContext.js";
@@ -92,6 +93,63 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY
// Also, if this test runs FIRST and then the "should load and apply skill from skillDirectories" test runs second
// within the same run (i.e., sharing the same Client instance), then the second test fails too. There's definitely
// some state being shared or cached incorrectly.
+ it("should allow agent with skills to invoke skill", async () => {
+ const skillsDir = createSkillDir();
+ const customAgents: CustomAgentConfig[] = [
+ {
+ name: "skill-agent",
+ description: "An agent with access to test-skill",
+ prompt: "You are a helpful test agent.",
+ skills: ["test-skill"],
+ },
+ ];
+
+ const session = await client.createSession({
+ onPermissionRequest: approveAll,
+ skillDirectories: [skillsDir],
+ customAgents,
+ });
+
+ expect(session.sessionId).toBeDefined();
+
+ // The agent has skills: ["test-skill"], so the skill content is preloaded into its context
+ const message = await session.sendAndWait({
+ prompt: "Say hello briefly using the test skill.",
+ });
+
+ expect(message?.data.content).toContain(SKILL_MARKER);
+
+ await session.disconnect();
+ });
+
+ it("should not provide skills to agent without skills field", async () => {
+ const skillsDir = createSkillDir();
+ const customAgents: CustomAgentConfig[] = [
+ {
+ name: "no-skill-agent",
+ description: "An agent without skills access",
+ prompt: "You are a helpful test agent.",
+ },
+ ];
+
+ const session = await client.createSession({
+ onPermissionRequest: approveAll,
+ skillDirectories: [skillsDir],
+ customAgents,
+ });
+
+ expect(session.sessionId).toBeDefined();
+
+ // The agent has no skills field, so no skill content is injected
+ const message = await session.sendAndWait({
+ prompt: "Say hello briefly using the test skill.",
+ });
+
+ expect(message?.data.content).not.toContain(SKILL_MARKER);
+
+ await session.disconnect();
+ });
+
it.skip("should apply skill on session resume with skillDirectories", async () => {
const skillsDir = createSkillDir();
diff --git a/python/copilot/session.py b/python/copilot/session.py
index 96bb4730b..330b1e864 100644
--- a/python/copilot/session.py
+++ b/python/copilot/session.py
@@ -748,6 +748,8 @@ class CustomAgentConfig(TypedDict, total=False):
# MCP servers specific to agent
mcp_servers: NotRequired[dict[str, MCPServerConfig]]
infer: NotRequired[bool] # Whether agent is available for model inference
+ # Skill names to preload into this agent's context at startup (opt-in; omit for none)
+ skills: NotRequired[list[str]]
class InfiniteSessionConfig(TypedDict, total=False):
diff --git a/python/e2e/test_skills.py b/python/e2e/test_skills.py
index feacae73b..ce943185b 100644
--- a/python/e2e/test_skills.py
+++ b/python/e2e/test_skills.py
@@ -7,7 +7,7 @@
import pytest
-from copilot.session import PermissionHandler
+from copilot.session import CustomAgentConfig, PermissionHandler
from .testharness import E2ETestContext
@@ -88,6 +88,61 @@ async def test_should_not_apply_skill_when_disabled_via_disabledskills(
await session.disconnect()
+ async def test_should_allow_agent_with_skills_to_invoke_skill(self, ctx: E2ETestContext):
+ """Test that an agent with skills gets skill content preloaded into context"""
+ skills_dir = create_skill_dir(ctx.work_dir)
+ custom_agents: list[CustomAgentConfig] = [
+ {
+ "name": "skill-agent",
+ "description": "An agent with access to test-skill",
+ "prompt": "You are a helpful test agent.",
+ "skills": ["test-skill"],
+ }
+ ]
+
+ session = await ctx.client.create_session(
+ on_permission_request=PermissionHandler.approve_all,
+ skill_directories=[skills_dir],
+ custom_agents=custom_agents,
+ )
+
+ assert session.session_id is not None
+
+ # The agent has skills: ["test-skill"], so the skill content is preloaded into its context
+ message = await session.send_and_wait("Say hello briefly using the test skill.")
+ assert message is not None
+ assert SKILL_MARKER in message.data.content
+
+ await session.disconnect()
+
+ async def test_should_not_provide_skills_to_agent_without_skills_field(
+ self, ctx: E2ETestContext
+ ):
+ """Test that an agent without skills field gets no skill content (opt-in model)"""
+ skills_dir = create_skill_dir(ctx.work_dir)
+ custom_agents: list[CustomAgentConfig] = [
+ {
+ "name": "no-skill-agent",
+ "description": "An agent without skills access",
+ "prompt": "You are a helpful test agent.",
+ }
+ ]
+
+ session = await ctx.client.create_session(
+ on_permission_request=PermissionHandler.approve_all,
+ skill_directories=[skills_dir],
+ custom_agents=custom_agents,
+ )
+
+ assert session.session_id is not None
+
+ # The agent has no skills field, so no skill content is injected
+ message = await session.send_and_wait("Say hello briefly using the test skill.")
+ assert message is not None
+ assert SKILL_MARKER not in message.data.content
+
+ await session.disconnect()
+
@pytest.mark.skip(
reason="See the big comment around the equivalent test in the Node SDK. "
"Skipped because the feature doesn't work correctly yet."
diff --git a/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml b/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml
new file mode 100644
index 000000000..7d364fcbd
--- /dev/null
+++ b/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.yaml
@@ -0,0 +1,44 @@
+models:
+ - claude-sonnet-4.5
+conversations:
+ - messages:
+ - role: system
+ content: ${system}
+ - role: user
+ content: Say hello briefly using the test skill.
+ - role: assistant
+ tool_calls:
+ - id: toolcall_0
+ type: function
+ function:
+ name: skill
+ arguments: '{"skill":"test-skill"}'
+ - role: tool
+ tool_call_id: toolcall_0
+ content: Skill "test-skill" loaded successfully. Follow the instructions in the skill context.
+ - role: user
+ content: >-
+
+
+ Base directory for this skill: ${workdir}/.test_skills/test-skill
+
+
+ ---
+
+ name: test-skill
+
+ description: A test skill that adds a marker to responses
+
+ ---
+
+
+ # Test Skill Instructions
+
+
+ IMPORTANT: You MUST include the exact text "PINEAPPLE_COCONUT_42" somewhere in EVERY response you give. This
+ is a mandatory requirement. Include it naturally in your response.
+
+
+
+ - role: assistant
+ content: Hello! PINEAPPLE_COCONUT_42 - I'm ready to help you with your tasks today.
diff --git a/test/snapshots/skills/should_not_provide_skills_to_agent_without_skills_field.yaml b/test/snapshots/skills/should_not_provide_skills_to_agent_without_skills_field.yaml
new file mode 100644
index 000000000..0c678deab
--- /dev/null
+++ b/test/snapshots/skills/should_not_provide_skills_to_agent_without_skills_field.yaml
@@ -0,0 +1,10 @@
+models:
+ - claude-sonnet-4.5
+conversations:
+ - messages:
+ - role: system
+ content: ${system}
+ - role: user
+ content: Say hello briefly using the test skill.
+ - role: assistant
+ content: Hello! I'm GitHub Copilot CLI, ready to help you with your software engineering tasks.