diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 748851b4f8c..fabe3fa5128 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -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({
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
index 888460e84fb..41df88f8b6a 100644
--- a/packages/opencode/src/skill/skill.ts
+++ b/packages/opencode/src/skill/skill.ts
@@ -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(),
@@ -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,
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 52d8e2cbb09..69a45432dc5 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -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
@@ -130,7 +130,7 @@ export namespace ToolRegistry {
using _ = log.time(t.id)
return {
id: t.id,
- ...(await t.init()),
+ ...(await t.init({ agent })),
}
}),
)
diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts
index ad5001da985..2503f76398b 100644
--- a/packages/opencode/src/tool/skill.ts
+++ b/packages/opencode/src/tool/skill.ts
@@ -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.",
- "",
- ...skills.flatMap((skill) => [
- ` `,
- ` ${skill.name}`,
- ` ${skill.description}`,
- ` `,
- ]),
- "",
- ].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 = {
+ 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.",
+ "",
+ ...accessibleSkills.flatMap((skill) => [
+ ` `,
+ ` ${skill.name}`,
+ ` ${skill.description}`,
+ ` `,
+ ]),
+ "",
+ ].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,
+ },
+ }
+ },
+ }
+ },
+}
diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts
index 80b6abe8c74..acee24902c1 100644
--- a/packages/opencode/src/tool/tool.ts
+++ b/packages/opencode/src/tool/tool.ts
@@ -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 = {
sessionID: string
messageID: string
@@ -17,7 +22,7 @@ export namespace Tool {
}
export interface Info {
id: string
- init: () => Promise<{
+ init: (ctx?: InitContext) => Promise<{
description: string
parameters: Parameters
execute(
@@ -42,8 +47,8 @@ export namespace Tool {
): Info {
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 {
diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx
index 217d4b3d25e..c1d433a839f 100644
--- a/packages/web/src/content/docs/skills.mdx
+++ b/packages/web/src/content/docs/skills.mdx
@@ -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.
---
@@ -97,24 +97,123 @@ Ask clarifying questions if the target versioning scheme is unclear.
---
-## Recognize prompt injection
+## Recognize tool description
-OpenCode adds an `` 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
git-release
Create consistent releases and changelogs
- .opencode/skill/git-release/SKILL.md
```
+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 `` 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