diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md
index 6c6455a02..f3c508922 100644
--- a/docs/features/custom-agents.md
+++ b/docs/features/custom-agents.md
@@ -252,6 +252,7 @@ try (var client = new CopilotClient()) {
| `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.
@@ -261,6 +262,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 882580fd4..6c3888eb8 100644
--- a/docs/features/skills.md
+++ b/docs/features/skills.md
@@ -364,7 +364,7 @@ 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({
@@ -373,10 +373,12 @@ const session = await client.createSession({
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
diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs
index 970d44f76..6c919d891 100644
--- a/dotnet/src/Types.cs
+++ b/dotnet/src/Types.cs
@@ -1638,6 +1638,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 ().
+ /// When omitted, no skills are injected (opt-in model).
+ ///
+ [JsonPropertyName("skills")]
+ public IList? Skills { get; set; }
}
///
diff --git a/dotnet/test/SkillsTests.cs b/dotnet/test/SkillsTests.cs
index d68eed79d..0cae1f58f 100644
--- a/dotnet/test/SkillsTests.cs
+++ b/dotnet/test/SkillsTests.cs
@@ -87,6 +87,69 @@ 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,
+ Agent = "skill-agent"
+ });
+
+ 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,
+ Agent = "no-skill-agent"
+ });
+
+ 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 f6943fef9..b91592d9d 100644
--- a/go/internal/e2e/skills_test.go
+++ b/go/internal/e2e/skills_test.go
@@ -108,6 +108,83 @@ 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,
+ Agent: "skill-agent",
+ })
+ 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 md, ok := message.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(md.Content, skillMarker) {
+ t.Errorf("Expected message to contain skill marker '%s', got: %v", skillMarker, message.Data)
+ }
+
+ 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,
+ Agent: "no-skill-agent",
+ })
+ 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 md, ok := message.Data.(*copilot.AssistantMessageData); ok && strings.Contains(md.Content, skillMarker) {
+ t.Errorf("Expected message to NOT contain skill marker '%s' when agent has no skills, got: %v", skillMarker, md.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 568bcc1b9..39f548490 100644
--- a/go/types.go
+++ b/go/types.go
@@ -450,6 +450,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/src/types.ts b/nodejs/src/types.ts
index cb8dd7ad2..a0ce54285 100644
--- a/nodejs/src/types.ts
+++ b/nodejs/src/types.ts
@@ -1106,6 +1106,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..973e2f329 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,65 @@ 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,
+ agent: "skill-agent",
+ });
+
+ 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,
+ agent: "no-skill-agent",
+ });
+
+ 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/client.py b/python/copilot/client.py
index d260dcc91..2078dfc6c 100644
--- a/python/copilot/client.py
+++ b/python/copilot/client.py
@@ -2158,6 +2158,8 @@ def _convert_custom_agent_to_wire_format(
wire_agent["mcpServers"] = agent["mcp_servers"]
if "infer" in agent:
wire_agent["infer"] = agent["infer"]
+ if "skills" in agent:
+ wire_agent["skills"] = agent["skills"]
return wire_agent
async def _start_cli_server(self) -> None:
diff --git a/python/copilot/session.py b/python/copilot/session.py
index 45e8826b7..5d33bd05b 100644
--- a/python/copilot/session.py
+++ b/python/copilot/session.py
@@ -766,6 +766,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..b5c5e6e7c 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,63 @@ 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,
+ agent="skill-agent",
+ )
+
+ 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,
+ agent="no-skill-agent",
+ )
+
+ 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/harness/replayingCapiProxy.test.ts b/test/harness/replayingCapiProxy.test.ts
index 6fcaed5e2..f19674052 100644
--- a/test/harness/replayingCapiProxy.test.ts
+++ b/test/harness/replayingCapiProxy.test.ts
@@ -302,6 +302,52 @@ describe("ReplayingCapiProxy", () => {
);
});
+ test("strips agent_instructions from user messages", async () => {
+ const requestBody = JSON.stringify({
+ messages: [
+ {
+ role: "user",
+ content:
+ "\nYou are a helpful test agent.\n\n\n\n\nSay hello briefly.",
+ },
+ ],
+ });
+ const responseBody = JSON.stringify({
+ choices: [{ message: { role: "assistant", content: "Hello!" } }],
+ });
+
+ const outputPath = await createProxy([
+ { url: "/chat/completions", requestBody, responseBody },
+ ]);
+
+ const result = await readYamlOutput(outputPath);
+ expect(result.conversations[0].messages[0].content).toBe(
+ "Say hello briefly.",
+ );
+ });
+
+ test("strips agent_instructions containing skill-context from user messages", async () => {
+ const requestBody = JSON.stringify({
+ messages: [
+ {
+ role: "user",
+ content:
+ '\n\nSkill content here\n\nYou are a helpful agent.\n\n\nSay hello.',
+ },
+ ],
+ });
+ const responseBody = JSON.stringify({
+ choices: [{ message: { role: "assistant", content: "Hi!" } }],
+ });
+
+ const outputPath = await createProxy([
+ { url: "/chat/completions", requestBody, responseBody },
+ ]);
+
+ const result = await readYamlOutput(outputPath);
+ expect(result.conversations[0].messages[0].content).toBe("Say hello.");
+ });
+
test("applies tool result normalizers to tool response content", async () => {
const requestBody = JSON.stringify({
messages: [
diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts
index 03dcd190f..a63c5b123 100644
--- a/test/harness/replayingCapiProxy.ts
+++ b/test/harness/replayingCapiProxy.ts
@@ -805,6 +805,7 @@ function normalizeUserMessage(content: string): string {
return content
.replace(/.*?<\/current_datetime>/g, "")
.replace(/[\s\S]*?<\/reminder>/g, "")
+ .replace(/[\s\S]*?<\/agent_instructions>/g, "")
.replace(
/Please create a detailed summary of the conversation so far\. The history is being compacted[\s\S]*/,
"${compaction_prompt}",
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..007c5c1c5
--- /dev/null
+++ b/test/snapshots/skills/should_allow_agent_with_skills_to_invoke_skill.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! 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.