Skip to content
Draft
28 changes: 28 additions & 0 deletions docs/features/custom-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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`.
Expand Down
7 changes: 5 additions & 2 deletions docs/features/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,20 +364,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:
Expand Down
10 changes: 10 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1638,6 +1638,16 @@ public class CustomAgentConfig
/// </summary>
[JsonPropertyName("infer")]
public bool? Infer { get; set; }

/// <summary>
/// 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).
/// </summary>
[JsonPropertyName("skills")]
public List<string>? Skills { get; set; }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-SDK consistency / .NET convention: This property uses the concrete List<string>? type, but the established convention throughout Types.cs is to use the interface type IList<string>? for public collection properties (see Tools at line 1622, SkillDirectories at line 1865, AvailableTools at line 1792, etc.).

Suggestion:

public IList<string>? Skills { get; set; }

This keeps the public API surface consistent with every other nullable list property in the file.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Use IList<string>? instead of List<string>? to match .NET SDK conventions.

Every other nullable string[]-equivalent property in this file uses the IList<string>? interface rather than the concrete List<string> type. For example, the adjacent properties DisabledSkills, SkillDirectories, Tools, etc. all use IList<string>?:

public IList<string>? SkillDirectories { get; set; }   // line ~1865
public IList<string>? DisabledSkills { get; set; }     // line ~1870
public IList<string>? Tools { get; set; }              // line ~1622

Suggestion:

public IList<string>? Skills { get; set; }

This keeps the public API surface consistent with the rest of the SDK's collection-typed properties.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-SDK consistency / .NET type convention: This property uses the concrete List<string>? type, but all comparable nullable string-list properties in this file use the interface type IList<string>? (e.g. DisabledSkills, AvailableTools, ExcludedTools, Tools, SkillDirectories).

Suggestion: change to match the established pattern:

public IList<string>? Skills { get; set; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The property type should be IList<string>? rather than List<string>? to be consistent with the rest of the public API surface in this file. Every other string[]-equivalent property in Types.cs (e.g. Tools, CleanupActions, SkillDirectories, DisabledSkills, ...) uses the interface type IList<string>?.

public IList<string>? Skills { get; set; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Skills property uses List<string>? while every other string-collection property in this file (including Tools right above it in the same class) uses the IList<string>? interface type. This is inconsistent with the established .NET API convention in this codebase.

// Suggested change
public IList<string>? Skills { get; set; }

For reference, the Tools property in this same class is declared as:

public IList<string>? Tools { get; set; }

And similarly, SkillDirectories, DisabledSkills, AvailableTools, etc. throughout the file all use IList<string>?.

}

/// <summary>
Expand Down
63 changes: 63 additions & 0 deletions dotnet/test/SkillsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CustomAgentConfig>
{
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<CustomAgentConfig>
{
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()
{
Expand Down
77 changes: 77 additions & 0 deletions go/internal/e2e/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand Down
60 changes: 60 additions & 0 deletions nodejs/test/e2e/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();

Expand Down
2 changes: 2 additions & 0 deletions python/copilot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions python/copilot/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading