Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@ export namespace SessionPrompt {
mergeDeep(await ToolRegistry.enabled(input.agent)),
mergeDeep(input.tools ?? {}),
)
for (const item of await ToolRegistry.tools(input.model.providerID)) {
for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
if (Wildcard.all(item.id, enabledTools) === false) continue
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/src/skill/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { NamedError } from "@opencode-ai/util/error"
import { ConfigMarkdown } from "../config/markdown"
import { Log } from "../util/log"

export namespace Skill {
const log = Log.create({ service: "skill" })
export const Info = z.object({
name: z.string(),
description: z.string(),
Expand Down Expand Up @@ -50,6 +52,16 @@ export namespace Skill {

const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) continue

// Warn on duplicate skill names
if (skills[parsed.data.name]) {
log.warn("duplicate skill name", {
name: parsed.data.name,
existing: skills[parsed.data.name].location,
duplicate: match,
})
}

skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export namespace ToolRegistry {
return all().then((x) => x.map((t) => t.id))
}

export async function tools(providerID: string) {
export async function tools(providerID: string, agent?: Agent.Info) {
const tools = await all()
const result = await Promise.all(
tools
Expand All @@ -130,7 +130,7 @@ export namespace ToolRegistry {
using _ = log.time(t.id)
return {
id: t.id,
...(await t.init()),
...(await t.init({ agent })),
}
}),
)
Expand Down
150 changes: 83 additions & 67 deletions packages/opencode/src/tool/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,78 +7,94 @@ import { Permission } from "../permission"
import { Wildcard } from "../util/wildcard"
import { ConfigMarkdown } from "../config/markdown"

export const SkillTool = Tool.define("skill", async () => {
const skills = await Skill.all()
return {
description: [
"Load a skill to get detailed instructions for a specific task.",
"Skills provide specialized knowledge and step-by-step guidance.",
"Use this when a task matches an available skill's description.",
"<available_skills>",
...skills.flatMap((skill) => [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` </skill>`,
]),
"</available_skills>",
].join(" "),
parameters: z.object({
name: z
.string()
.describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"),
}),
async execute(params, ctx) {
const agent = await Agent.get(ctx.agent)
const parameters = z.object({
name: z.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
})

const skill = await Skill.get(params.name)
export const SkillTool: Tool.Info<typeof parameters> = {
id: "skill",
async init(ctx) {
const skills = await Skill.all()

if (!skill) {
const available = Skill.all().then((x) => Object.keys(x).join(", "))
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
// Filter skills by agent permissions if agent provided
let accessibleSkills = skills
if (ctx?.agent) {
const permissions = ctx.agent.permission.skill
accessibleSkills = skills.filter((skill) => {
const action = Wildcard.all(skill.name, permissions)
return action !== "deny"
})
}

// Check permission using Wildcard.all on the skill ID
const permissions = agent.permission.skill
const action = Wildcard.all(params.name, permissions)
return {
description: [
"Load a skill to get detailed instructions for a specific task.",
"Skills provide specialized knowledge and step-by-step guidance.",
"Use this when a task matches an available skill's description.",
"<available_skills>",
...accessibleSkills.flatMap((skill) => [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` </skill>`,
]),
"</available_skills>",
].join(" "),
parameters,
async execute(params, ctx) {
const agent = await Agent.get(ctx.agent)

if (action === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"skill",
ctx.callID,
{ skill: params.name },
`Access to skill "${params.name}" is denied for agent "${agent.name}".`,
)
}
const skill = await Skill.get(params.name)

if (action === "ask") {
await Permission.ask({
type: "skill",
pattern: params.name,
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Load skill: ${skill.name}`,
metadata: { id: params.name, name: skill.name, description: skill.description },
})
}
if (!skill) {
const available = await Skill.all().then((x) => x.map((s) => s.name).join(", "))
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}

// Load and parse skill content
const parsed = await ConfigMarkdown.parse(skill.location)
const dir = path.dirname(skill.location)
// Check permission using Wildcard.all on the skill name
const permissions = agent.permission.skill
const action = Wildcard.all(params.name, permissions)

// Format output similar to plugin pattern
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n")
if (action === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"skill",
ctx.callID,
{ skill: params.name },
`Access to skill "${params.name}" is denied for agent "${agent.name}".`,
)
}

return {
title: `Loaded skill: ${skill.name}`,
output,
metadata: {
name: skill.name,
dir,
},
}
},
}
})
if (action === "ask") {
await Permission.ask({
type: "skill",
pattern: params.name,
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Load skill: ${skill.name}`,
metadata: { name: skill.name, description: skill.description },
})
}

// Load and parse skill content
const parsed = await ConfigMarkdown.parse(skill.location)
const dir = path.dirname(skill.location)

// Format output similar to plugin pattern
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join(
"\n",
)

return {
title: `Loaded skill: ${skill.name}`,
output,
metadata: {
name: skill.name,
dir,
},
}
},
}
},
}
11 changes: 8 additions & 3 deletions packages/opencode/src/tool/tool.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import z from "zod"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"

export namespace Tool {
interface Metadata {
[key: string]: any
}

export interface InitContext {
agent?: Agent.Info
}

export type Context<M extends Metadata = Metadata> = {
sessionID: string
messageID: string
Expand All @@ -17,7 +22,7 @@ export namespace Tool {
}
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
init: () => Promise<{
init: (ctx?: InitContext) => Promise<{
description: string
parameters: Parameters
execute(
Expand All @@ -42,8 +47,8 @@ export namespace Tool {
): Info<Parameters, Result> {
return {
id,
init: async () => {
const toolInfo = init instanceof Function ? await init() : init
init: async (ctx) => {
const toolInfo = init instanceof Function ? await init(ctx) : init
const execute = toolInfo.execute
toolInfo.execute = (args, ctx) => {
try {
Expand Down
113 changes: 106 additions & 7 deletions packages/web/src/content/docs/skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: "Define reusable behavior via SKILL.md definitions"
---

Agent skills let OpenCode discover reusable instructions from your repo or home directory.
When a conversation matches a skill, the agent is prompted to read its `SKILL.md`.
Skills are loaded on-demand via the native `skill` tool—agents see available skills and can load the full content when needed.

---

Expand Down Expand Up @@ -97,24 +97,123 @@ Ask clarifying questions if the target versioning scheme is unclear.

---

## Recognize prompt injection
## Recognize tool description

OpenCode adds an `<available_skills>` XML block to the system prompt.
Each entry includes the skill name, description, and its discovered location.
OpenCode lists available skills in the `skill` tool description.
Each entry includes the skill name and description:

```xml
<available_skills>
<skill>
<name>git-release</name>
<description>Create consistent releases and changelogs</description>
<location>.opencode/skill/git-release/SKILL.md</location>
</skill>
</available_skills>
```

The agent loads a skill by calling the tool:

```
skill({ name: "git-release" })
```

---

## Configure permissions

Control which skills agents can access using pattern-based permissions in `opencode.json`:

```json
{
"permission": {
"skill": {
"pr-review": "allow",
"internal-*": "deny",
"experimental-*": "ask",
"*": "allow"
}
}
}
```

| Permission | Behavior |
| ---------- | ----------------------------------------- |
| `allow` | Skill loads immediately |
| `deny` | Skill hidden from agent, access rejected |
| `ask` | User prompted for approval before loading |

Patterns support wildcards: `internal-*` matches `internal-docs`, `internal-tools`, etc.

---

## Override per agent

Give specific agents different permissions than the global defaults.

**For custom agents** (in agent frontmatter):

```yaml
---
permission:
skill:
"documents-*": "allow"
---
```

**For built-in agents** (in `opencode.json`):

```json
{
"agent": {
"plan": {
"permission": {
"skill": {
"internal-*": "allow"
}
}
}
}
}
```

---

## Disable the skill tool

Completely disable skills for agents that shouldn't use them:

**For custom agents**:

```yaml
---
tools:
skill: false
---
```

**For built-in agents**:

```json
{
"agent": {
"plan": {
"tools": {
"skill": false
}
}
}
}
```

When disabled, the `<available_skills>` section is omitted entirely.

---

## Troubleshoot loading

If a skill does not show up, verify the folder name matches `name` exactly.
Also check that `SKILL.md` is spelled in all caps and includes frontmatter.
If a skill does not show up:

1. Verify `SKILL.md` is spelled in all caps
2. Check that frontmatter includes `name` and `description`
3. Ensure skill names are unique across all locations
4. Check permissions—skills with `deny` are hidden from agents