Agent Skills, the open SKILL.md standard for agents
Agent Skills is an open format for packaging procedural knowledge for AI agents. A single skill is a directory holding a SKILL.md file describing how to carry out a particular task, and the agent loads that description only when it is needed. Anthropic developed the format and released it as an open standard, subsequently adopted by dozens of competing tools.
Where the standard came from and why it won
The specification was published on 18 December 2025, and the pace of adoption was unusual even for this industry. Within months the same SKILL.md file in the same directory was being read by tools from companies competing with each other: Claude Code, VS Code with Copilot, OpenAI's Codex, Cursor, Google's Gemini CLI, JetBrains' Junie, Amazon's Kiro, and goose.
The reason for that agreement is more pragmatic than idealistic. Each of those tools had its own way of accepting instructions from a user, so a team working across three different agents maintained three versions of the same knowledge. A shared format removes that without handing control to anyone, since it is an ordinary directory holding a text file rather than a service you connect to.
The standard is stewarded by the Agentic AI Foundation at the Linux Foundation, the same body that received the Model Context Protocol. For a team weighing an investment in writing skills that matters: the format does not depend on one company's priorities.
What a skill looks like
A skill is a directory in which only one file is mandatory.
my-skill/
βββ SKILL.md # required: metadata and instructions
βββ scripts/ # optional: executable code
βββ references/ # optional: documentation
βββ assets/ # optional: templates and resourcesThe file itself opens with metadata, where the minimum is a name and a description, followed by plain text instructions.
---
name: weekly-report
description: Generates a weekly report from support tickets. Use when someone asks for a summary of the week or a ticket breakdown.
---
# Weekly report
1. Fetch the last seven days of tickets using `scripts/fetch.py`.
2. Group them by the categories in `references/categories.md`.
3. Assemble the report using the `assets/template.md` layout.
4. Do not add conclusions the data does not support.The description field matters more than it looks, and I return to it under common mistakes. It is the only thing the agent sees at startup, so how it is phrased decides whether the skill gets used at all.
Progressive disclosure, the heart of the idea
The mechanism the whole format rests on is called progressive disclosure and works in three stages.
At startup the agent loads only the name and description of each available skill, a few dozen words apiece. That is enough to know when a skill might apply, and it costs so little that you can keep dozens on hand.
When a task matches a description, the agent loads the file's full contents into context. Only at that point do you pay for longer instructions, and only when they are needed.
In the third stage the agent follows those instructions, reaching for bundled scripts and supporting files as required. A ten thousand word reference document costs nothing until an instruction tells the agent to open it.
That construction solves a problem which previously had no good answer. Dumping a team's entire knowledge into one instructions file works with one page and stops working at twenty, because all of it enters context on every task and crowds out what actually matters. Skills invert that: the knowledge can be arbitrarily large, since only the matching part loads.
Skills against MCP, two different layers
This distinction generates the most confusion, so it is worth stating plainly.
The Model Context Protocol gives an agent capabilities: access to a database, a repository, a browser. It answers what the agent can do. Skills give an agent knowledge: how to perform a specific task in your organisation, in what order, what to avoid. They answer how to do it well.
It follows that they do not compete but complement each other, and most often appear together. A skill describing a deployment process uses an MCP server providing access to the deployment system, and without it would be instructions without tools. Conversely, an MCP server alone provides access with no knowledge of how to use it according to your rules. There is more on the protocol itself in the MCP toolkit writeup.
A practical selection rule: if the agent lacks access to something, you need an MCP server. If the agent has access but does things contrary to your process, you need a skill.
What are Agent Skills?
Agent Skills are patterns, tools, and capabilities that allow AI agents to perform specialized tasks beyond text generation. When a language model (LLM) has access to skills/tools, it becomes a true agent - it can interact with the external world: scrape websites, execute database queries, manipulate files, send emails, and integrate with any API.
The concept of skills/tools is the foundation of modern AI systems and appears under different names depending on the platform:
- Tool Use (Anthropic Claude)
- Function Calling (OpenAI)
- Tools (LangChain)
- MCP (Model Context Protocol - Anthropic)
- Actions (GPTs)
- Skills (Custom agents)
Evolution from chatbots to agents
Traditional chatbots could only generate text based on a prompt. Modern AI agents can:
- Analyze - Understand the user's task
- Plan - Decide which tools to use
- Execute - Call the appropriate skills/tools
- Iterate - Use results for further actions
- Report - Present the final result
This "ReAct" (Reasoning + Acting) model allows agents to solve complex, multi-step problems.
System architecture with skills
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β User Request β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β AI Agent (LLM) β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β System Prompt + Context + Conversation History β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Available Tools/Skills (descriptions + schemas) β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ (Tool Call)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Skills Registry β
β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ β
β βWeb Scraperβ β Database β β FileSystemβ β API β β
β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ (Tool Result)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β AI Agent (continues reasoning) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Final Response β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββWhy are Skills important?
1. Breaking LLM limitations
Language models on their own:
- Have no access to the internet in real time
- Cannot execute code
- Do not know current data (knowledge cutoff)
- Cannot modify external systems
Skills solve these limitations by giving the LLM "hands" to act in the world.
2. Specialization and modularity
Instead of trying to build a single "super-model" that can do everything, skills allow you to:
- Add specialized capabilities in a modular way
- Update individual functions without changing the entire system
- Combine different skills depending on needs
- Test each skill independently
3. Security and control
Skills act as a controlled interface between AI and the world:
- You can limit what the agent can do
- You can log and audit all actions
- You can add rate limiting and sandboxing
- You can require approval for dangerous operations
4. Reducing hallucinations
When an LLM has access to real data through skills:
- It does not have to "guess" information
- It can verify facts before responding
- Responses are based on current data
- Lower risk of confabulation
Anatomy of a Skill
Basic skill structure
interface Skill {
// Identifier used by LLM for invocation
name: string
// Description for LLM - what this skill does
description: string
// Parameter schema (JSON Schema or Zod)
inputSchema: JSONSchema | ZodSchema
// Optional output schema
outputSchema?: JSONSchema | ZodSchema
// Main execution logic
execute: (params: unknown) => Promise<unknown>
// Optional metadata
metadata?: {
category?: string
requiresAuth?: boolean
rateLimit?: number
timeout?: number
}
}Complete skill example
import { z } from 'zod'
// Parameter schema with Zod
const weatherInputSchema = z.object({
city: z.string().describe('City name'),
units: z.enum(['metric', 'imperial']).default('metric').describe('Temperature units'),
lang: z.string().default('en').describe('Response language')
})
// Output schema
const weatherOutputSchema = z.object({
temperature: z.number(),
description: z.string(),
humidity: z.number(),
wind_speed: z.number()
})
// Skill definition
export const weatherSkill: Skill = {
name: 'get_weather',
description: `Fetches the current weather for a given city.
Use this tool when the user asks about the weather, temperature,
whether it will rain, or what atmospheric conditions are like.`,
inputSchema: weatherInputSchema,
outputSchema: weatherOutputSchema,
metadata: {
category: 'information',
requiresAuth: true, // Requires API key
rateLimit: 60, // Max 60 requests/minute
timeout: 5000 // 5 second timeout
},
async execute(params) {
const { city, units, lang } = weatherInputSchema.parse(params)
const apiKey = process.env.OPENWEATHER_API_KEY
const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&units=${units}&lang=${lang}&appid=${apiKey}`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Weather API error: ${response.status}`)
}
const data = await response.json()
return weatherOutputSchema.parse({
temperature: data.main.temp,
description: data.weather[0].description,
humidity: data.main.humidity,
wind_speed: data.wind.speed
})
}
}Popular skill categories
1. Web & Internet Skills
export const webScraperSkill = {
name: 'web_scrape',
description: 'Fetches and parses content from a web page',
inputSchema: z.object({
url: z.string().url(),
selector: z.string().optional().describe('CSS selector for specific elements'),
format: z.enum(['text', 'html', 'markdown']).default('text')
}),
async execute({ url, selector, format }) {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; Bot/1.0)'
}
})
const html = await response.text()
const dom = new JSDOM(html)
const document = dom.window.document
let content: string
if (selector) {
const elements = document.querySelectorAll(selector)
content = Array.from(elements).map(el => el.textContent).join('\n')
} else {
const article = document.querySelector('article, main, .content')
content = article?.textContent || document.body.textContent || ''
}
if (format === 'markdown') {
return turndownService.turndown(content)
}
return content.trim()
}
}
export const searchSkill = {
name: 'web_search',
description: 'Searches the internet using DuckDuckGo/Serper',
inputSchema: z.object({
query: z.string(),
num_results: z.number().min(1).max(10).default(5)
}),
async execute({ query, num_results }) {
const response = await fetch('https://google.serper.dev/search', {
method: 'POST',
headers: {
'X-API-KEY': process.env.SERPER_API_KEY!,
'Content-Type': 'application/json'
},
body: JSON.stringify({ q: query, num: num_results })
})
const data = await response.json()
return data.organic.map((result: any) => ({
title: result.title,
link: result.link,
snippet: result.snippet
}))
}
}2. Database Skills
export const databaseQuerySkill = {
name: 'database_query',
description: `Executes a safe SQL query against the database.
ONLY SELECT queries are allowed. Use parameters for values.`,
inputSchema: z.object({
query: z.string().describe('SQL SELECT query'),
params: z.array(z.any()).default([]).describe('Query parameters')
}),
async execute({ query, params }) {
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery.startsWith('select')) {
throw new Error('Only SELECT queries are allowed')
}
const forbidden = ['drop', 'delete', 'update', 'insert', 'alter', 'create', 'truncate']
for (const word of forbidden) {
if (normalizedQuery.includes(word)) {
throw new Error(`Forbidden keyword: ${word}`)
}
}
const result = await db.query(query, params)
return result.rows.slice(0, 100)
}
}
export const prismaSkill = {
name: 'prisma_query',
description: 'Executes a type-safe query through Prisma ORM',
inputSchema: z.object({
model: z.enum(['User', 'Post', 'Comment', 'Product']),
operation: z.enum(['findMany', 'findFirst', 'count']),
where: z.record(z.any()).optional(),
select: z.array(z.string()).optional(),
take: z.number().max(100).optional(),
skip: z.number().optional(),
orderBy: z.record(z.enum(['asc', 'desc'])).optional()
}),
async execute({ model, operation, where, select, take, skip, orderBy }) {
const prismaModel = prisma[model.toLowerCase() as keyof typeof prisma]
const query: any = {
where,
take: take || 50,
skip
}
if (select) {
query.select = Object.fromEntries(select.map(s => [s, true]))
}
if (orderBy) {
query.orderBy = orderBy
}
// @ts-ignore - dynamic model access
return await prismaModel[operation](query)
}
}3. File System Skills
export const fileSystemSkill = {
name: 'filesystem',
description: 'File system operations in a sandboxed directory',
inputSchema: z.object({
action: z.enum(['read', 'write', 'list', 'exists', 'mkdir', 'delete']),
path: z.string(),
content: z.string().optional()
}),
metadata: {
sandboxPath: '/workspace',
maxFileSize: 10 * 1024 * 1024
},
async execute({ action, path, content }) {
const sandboxPath = '/workspace'
const fullPath = join(sandboxPath, path)
const normalizedPath = normalize(fullPath)
if (!normalizedPath.startsWith(sandboxPath)) {
throw new Error('Path traversal attack detected')
}
switch (action) {
case 'read':
const fileContent = await fs.readFile(normalizedPath, 'utf-8')
return { content: fileContent.slice(0, 100000) }
case 'write':
if (!content) throw new Error('Content required for write')
if (content.length > 10 * 1024 * 1024) throw new Error('File too large')
await fs.writeFile(normalizedPath, content)
return { success: true, path: normalizedPath }
case 'list':
const entries = await fs.readdir(normalizedPath, { withFileTypes: true })
return entries.map(e => ({
name: e.name,
type: e.isDirectory() ? 'directory' : 'file'
}))
case 'exists':
try {
await fs.access(normalizedPath)
return { exists: true }
} catch {
return { exists: false }
}
case 'mkdir':
await fs.mkdir(normalizedPath, { recursive: true })
return { success: true }
case 'delete':
await fs.unlink(normalizedPath)
return { success: true }
}
}
}4. Code Execution Skills
export const codeExecutionSkill = {
name: 'execute_code',
description: 'Executes JavaScript code in a secure sandbox',
inputSchema: z.object({
code: z.string(),
language: z.enum(['javascript', 'typescript']).default('javascript'),
timeout: z.number().max(30000).default(5000)
}),
async execute({ code, language, timeout }) {
const vm = new NodeVM({
timeout,
sandbox: {
console: {
log: (...args: any[]) => logs.push(args.join(' ')),
error: (...args: any[]) => errors.push(args.join(' '))
}
},
require: {
external: false,
builtin: ['util', 'path']
}
})
const logs: string[] = []
const errors: string[] = []
try {
let executableCode = code
if (language === 'typescript') {
const result = ts.transpileModule(code, {
compilerOptions: { module: ts.ModuleKind.CommonJS }
})
executableCode = result.outputText
}
const result = vm.run(executableCode)
return {
success: true,
result,
logs,
errors
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
logs,
errors
}
}
}
}
export const pythonSkill = {
name: 'execute_python',
description: 'Executes Python code',
inputSchema: z.object({
code: z.string(),
timeout: z.number().max(60000).default(10000)
}),
async execute({ code, timeout }) {
return new Promise((resolve, reject) => {
const python = spawn('python3', ['-c', code], {
timeout,
env: {
...process.env,
PYTHONDONTWRITEBYTECODE: '1'
}
})
let stdout = ''
let stderr = ''
python.stdout.on('data', (data) => { stdout += data })
python.stderr.on('data', (data) => { stderr += data })
python.on('close', (exitCode) => {
resolve({
success: exitCode === 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode
})
})
python.on('error', (err) => {
reject(new Error(`Python execution failed: ${err.message}`))
})
})
}
}5. API Integration Skills
export const apiCallerSkill = {
name: 'api_call',
description: 'Executes an HTTP request to an external API',
inputSchema: z.object({
url: z.string().url(),
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('GET'),
headers: z.record(z.string()).optional(),
body: z.any().optional(),
timeout: z.number().max(30000).default(10000)
}),
async execute({ url, method, headers, body, timeout }) {
const allowedDomains = [
'api.openai.com',
'api.anthropic.com',
'api.github.com',
'api.stripe.com'
]
const urlObj = new URL(url)
if (!allowedDomains.some(d => urlObj.hostname.endsWith(d))) {
throw new Error(`Domain not allowed: ${urlObj.hostname}`)
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
...headers
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal
})
const contentType = response.headers.get('content-type')
const data = contentType?.includes('application/json')
? await response.json()
: await response.text()
return {
status: response.status,
statusText: response.statusText,
data
}
} finally {
clearTimeout(timeoutId)
}
}
}
export const githubSkill = {
name: 'github',
description: 'Interaction with GitHub API - repos, issues, PRs',
inputSchema: z.object({
action: z.enum([
'get_repo',
'list_issues',
'create_issue',
'get_pr',
'list_prs',
'get_file'
]),
owner: z.string(),
repo: z.string(),
params: z.record(z.any()).optional()
}),
async execute({ action, owner, repo, params }) {
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN
})
switch (action) {
case 'get_repo':
return await octokit.repos.get({ owner, repo })
case 'list_issues':
return await octokit.issues.listForRepo({
owner,
repo,
state: params?.state || 'open',
per_page: params?.limit || 10
})
case 'create_issue':
return await octokit.issues.create({
owner,
repo,
title: params?.title,
body: params?.body,
labels: params?.labels
})
case 'list_prs':
return await octokit.pulls.list({
owner,
repo,
state: params?.state || 'open',
per_page: params?.limit || 10
})
case 'get_file':
const content = await octokit.repos.getContent({
owner,
repo,
path: params?.path
})
if ('content' in content.data) {
return {
...content.data,
decoded: Buffer.from(content.data.content, 'base64').toString()
}
}
return content.data
}
}
}6. Communication Skills
export const emailSkill = {
name: 'send_email',
description: 'Sends an email via Resend/SendGrid',
inputSchema: z.object({
to: z.string().email(),
subject: z.string().max(200),
body: z.string().max(10000),
html: z.boolean().default(false)
}),
metadata: {
requiresApproval: true,
rateLimit: 10
},
async execute({ to, subject, body, html }) {
const resend = new Resend(process.env.RESEND_API_KEY)
const result = await resend.emails.send({
from: 'assistant@example.com',
to,
subject,
[html ? 'html' : 'text']: body
})
return { success: true, id: result.id }
}
}
export const slackSkill = {
name: 'slack_message',
description: 'Sends a message on Slack',
inputSchema: z.object({
channel: z.string(),
message: z.string(),
thread_ts: z.string().optional()
}),
async execute({ channel, message, thread_ts }) {
const slack = new WebClient(process.env.SLACK_BOT_TOKEN)
const result = await slack.chat.postMessage({
channel,
text: message,
thread_ts
})
return {
success: true,
ts: result.ts,
channel: result.channel
}
}
}Skills Registry
Registry implementation
import { Skill } from './types'
class SkillsRegistry {
private skills: Map<string, Skill> = new Map()
private categories: Map<string, Skill[]> = new Map()
register(skill: Skill) {
this.validateSkill(skill)
this.skills.set(skill.name, skill)
const category = skill.metadata?.category || 'general'
if (!this.categories.has(category)) {
this.categories.set(category, [])
}
this.categories.get(category)!.push(skill)
console.log(`Registered skill: ${skill.name}`)
}
private validateSkill(skill: Skill) {
if (!skill.name || typeof skill.name !== 'string') {
throw new Error('Skill must have a name')
}
if (!skill.description || typeof skill.description !== 'string') {
throw new Error('Skill must have a description')
}
if (typeof skill.execute !== 'function') {
throw new Error('Skill must have an execute function')
}
if (this.skills.has(skill.name)) {
throw new Error(`Skill ${skill.name} already registered`)
}
}
get(name: string): Skill | undefined {
return this.skills.get(name)
}
getAll(): Skill[] {
return Array.from(this.skills.values())
}
getByCategory(category: string): Skill[] {
return this.categories.get(category) || []
}
toToolDefinitions() {
return this.getAll().map(skill => ({
name: skill.name,
description: skill.description,
input_schema: this.zodToJsonSchema(skill.inputSchema)
}))
}
private zodToJsonSchema(schema: z.ZodSchema) {
return zodToJsonSchema(schema)
}
async execute(name: string, params: unknown) {
const skill = this.get(name)
if (!skill) {
throw new Error(`Skill not found: ${name}`)
}
if (skill.metadata?.rateLimit) {
await this.checkRateLimit(name, skill.metadata.rateLimit)
}
const timeout = skill.metadata?.timeout || 30000
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error(`Skill ${name} timed out`)), timeout)
})
try {
const result = await Promise.race([
skill.execute(params),
timeoutPromise
])
this.logExecution(name, params, result, true)
return result
} catch (error) {
this.logExecution(name, params, error, false)
throw error
}
}
private async checkRateLimit(skillName: string, limit: number) {
const key = `ratelimit:${skillName}`
const count = await redis.incr(key)
if (count === 1) {
await redis.expire(key, 60)
}
if (count > limit) {
throw new Error(`Rate limit exceeded for skill: ${skillName}`)
}
}
private logExecution(name: string, params: unknown, result: unknown, success: boolean) {
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
skill: name,
params: this.sanitizeForLog(params),
success,
result: success ? this.sanitizeForLog(result) : result
}))
}
private sanitizeForLog(data: unknown): unknown {
if (typeof data === 'object' && data !== null) {
const sanitized = { ...data as object }
const sensitiveKeys = ['password', 'token', 'apiKey', 'secret']
for (const key of sensitiveKeys) {
if (key in sanitized) {
(sanitized as any)[key] = '[REDACTED]'
}
}
return sanitized
}
return data
}
}
export const skillsRegistry = new SkillsRegistry()
import { webScraperSkill } from './skills/web-scraper'
import { databaseQuerySkill } from './skills/database'
import { fileSystemSkill } from './skills/filesystem'
import { weatherSkill } from './skills/weather'
import { githubSkill } from './skills/github'
skillsRegistry.register(webScraperSkill)
skillsRegistry.register(databaseQuerySkill)
skillsRegistry.register(fileSystemSkill)
skillsRegistry.register(weatherSkill)
skillsRegistry.register(githubSkill)Integration with Claude
Tool Use API
import Anthropic from '@anthropic-ai/sdk'
import { skillsRegistry } from './skills/registry'
const anthropic = new Anthropic()
async function runAgentWithTools(userMessage: string) {
const tools = skillsRegistry.toToolDefinitions()
let response = await anthropic.messages.create({
model: 'claude-opus-5',
max_tokens: 4096,
system: `You are a helpful assistant with access to tools.
Use tools when you need current information
or to perform an action. Respond in English.`,
tools,
messages: [{ role: 'user', content: userMessage }]
})
while (response.stop_reason === 'tool_use') {
const toolUseBlocks = response.content.filter(
block => block.type === 'tool_use'
)
const toolResults = await Promise.all(
toolUseBlocks.map(async (block) => {
if (block.type !== 'tool_use') return null
try {
const result = await skillsRegistry.execute(block.name, block.input)
return {
type: 'tool_result' as const,
tool_use_id: block.id,
content: JSON.stringify(result)
}
} catch (error) {
return {
type: 'tool_result' as const,
tool_use_id: block.id,
content: JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error'
}),
is_error: true
}
}
})
)
response = await anthropic.messages.create({
model: 'claude-opus-5',
max_tokens: 4096,
system: `You are a helpful assistant with access to tools.`,
tools,
messages: [
{ role: 'user', content: userMessage },
{ role: 'assistant', content: response.content },
{ role: 'user', content: toolResults.filter(Boolean) as any }
]
})
}
const textBlock = response.content.find(block => block.type === 'text')
return textBlock?.type === 'text' ? textBlock.text : ''
}
const answer = await runAgentWithTools(
'What is the weather in Warsaw and how many open issues do I have on the example/test repo?'
)MCP (Model Context Protocol)
MCP is a newer standard from Anthropic for tool integration:
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
const server = new Server(
{ name: 'my-skills-server', version: '1.0.0' },
{ capabilities: { tools: {} } }
)
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'get_weather',
description: 'Fetches the current weather for a city',
inputSchema: {
type: 'object',
properties: {
city: { type: 'string', description: 'City name' }
},
required: ['city']
}
}
]
}
})
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
switch (name) {
case 'get_weather':
const weather = await getWeather(args.city)
return {
content: [{ type: 'text', text: JSON.stringify(weather) }]
}
default:
throw new Error(`Unknown tool: ${name}`)
}
})
const transport = new StdioServerTransport()
await server.connect(transport)Best practices
1. Security first
async execute(params) {
const validated = inputSchema.parse(params)
validated.query = sanitizeHtml(validated.query)
if (!userHasPermission('skill:execute')) {
throw new Error('Permission denied')
}
// ...
}2. Error handling
async execute(params) {
try {
const result = await someOperation()
return { success: true, data: result }
} catch (error) {
console.error('Skill error:', error)
return {
success: false,
error: error instanceof Error
? error.message
: 'An error occurred'
}
}
}3. Idempotency
async execute({ action, data, idempotencyKey }) {
const existing = await cache.get(`idempotent:${idempotencyKey}`)
if (existing) {
return existing
}
const result = await performAction(data)
await cache.set(`idempotent:${idempotencyKey}`, result, 3600)
return result
}4. Logging & monitoring
const executeWithLogging = async (skill: Skill, params: unknown) => {
const startTime = Date.now()
const requestId = crypto.randomUUID()
console.log(JSON.stringify({
event: 'skill_start',
requestId,
skill: skill.name,
params: sanitize(params),
timestamp: new Date().toISOString()
}))
try {
const result = await skill.execute(params)
console.log(JSON.stringify({
event: 'skill_success',
requestId,
skill: skill.name,
duration: Date.now() - startTime,
resultSize: JSON.stringify(result).length
}))
return result
} catch (error) {
console.log(JSON.stringify({
event: 'skill_error',
requestId,
skill: skill.name,
duration: Date.now() - startTime,
error: error instanceof Error ? error.message : 'Unknown'
}))
throw error
}
}5. Rate limiting & throttling
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1m'),
})
async function executeWithRateLimit(skillName: string, userId: string) {
const { success, limit, remaining, reset } = await ratelimit.limit(
`${skillName}:${userId}`
)
if (!success) {
throw new Error(`Rate limit exceeded. Try again in ${reset}ms`)
}
// Execute skill...
}Testing skills
import { describe, it, expect, vi } from 'vitest'
import { weatherSkill } from './skills/weather'
describe('weatherSkill', () => {
it('should return weather data for valid city', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: true,
json: async () => ({
main: { temp: 20, humidity: 65 },
weather: [{ description: 'sunny' }],
wind: { speed: 5 }
})
} as Response)
const result = await weatherSkill.execute({ city: 'Warsaw' })
expect(result).toEqual({
temperature: 20,
description: 'sunny',
humidity: 65,
wind_speed: 5
})
})
it('should throw error for invalid city', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: false,
status: 404
} as Response)
await expect(
weatherSkill.execute({ city: 'NonExistentCity12345' })
).rejects.toThrow('Weather API error: 404')
})
it('should validate input schema', async () => {
await expect(
weatherSkill.execute({ city: 123 })
).rejects.toThrow()
})
})Pricing
| Component | Cost |
|---|---|
| Skill development | Developer time |
| Claude API (tool use) | Standard API rates |
| MCP Server hosting | Depends on infrastructure |
| Third-party APIs | Depends on provider |
Skills are code - there are no additional fees for using them. The costs are:
- API calls to LLM (Claude, OpenAI)
- Infrastructure hosting (servers, databases)
- Third-party services (weather API, GitHub, etc.)
FAQ - frequently asked questions
How many skills can an agent have?
The format itself sets no limit, and progressive disclosure means only names and descriptions load at startup. The limits come from elsewhere:
- every description occupies fixed space in the context on every request, so several dozen skills is already noticeable overhead;
- the more similar the descriptions, the more often the agent reaches for the wrong one;
- individual products impose their own caps, for instance an agent in Claude Managed Agents accepts at most twenty skills.
Two things are worth separating here, because they get conflated constantly. Skill count is not tool count. For very large tool sets there is a separate search mechanism that pulls tool definitions in on demand instead of holding them all in context. The practical limit is therefore qualitative: a dozen or so cleanly separated skills work better than a hundred overlapping ones.
How to choose between Tool Use and MCP?
- Tool Use API: Simpler, direct integration, less setup
- MCP: Standard protocol, better tooling, reusable servers
For simple use cases, use Tool Use. For complex systems with many integrations, consider MCP.
Can skills call other skills?
Yes, but with caution. A better approach:
- The LLM decides on the sequence of skills
- Or use an "orchestrator" skill that coordinates others
How to handle long-running operations?
async execute(params) {
const jobId = await startLongJob(params)
return {
status: 'started',
jobId,
checkStatusWith: 'check_job_status'
}
}How to secure sensitive skills?
- Approval workflow - Some skills require user confirmation
- Scoped permissions - Different users have access to different skills
- Audit logging - Log all invocations
- Rate limiting - Limit frequency
Can I use skills with different LLMs?
Yes, and that is the point of the standard. The same directory holding a SKILL.md file is read today by tools from different vendors, so a skill written once works regardless of which agent you happen to be using.
How to write good skills
Experience with this format comes down to a handful of rules absent from the specification that decide whether a skill gets used or gathers dust.
Write the description for retrieval, not for documentation. The agent sees only the name and description at startup, so that is the only moment it decides to use the skill. A description saying what the skill is performs worse than one saying when to use it. A sentence like "Use when someone asks for a summary of the week or a ticket breakdown" lands better than "A reporting tool".
Write instructions as a procedure, not as prose. A numbered list of steps naming specific files and commands works markedly better than a paragraph explaining the idea. The agent does not need historical context, only an order of operations.
Record prohibitions too, not just instructions. "Do not modify files in the migrations directory" is often more valuable than three sentences about what to do, because it blocks the most common mistake. A skill is the right home for the tribal knowledge usually passed on verbally during someone's first week.
Keep one skill to one task. It is tempting to build a single large skill describing an entire development process, but its description then turns generic and the agent cannot tell when to load it. Five narrow skills with precise descriptions outperform one broad one.
Version them alongside the code. A skill describing a process that changed six months ago is worse than no skill, because the agent will follow an outdated procedure with full confidence. Keeping the directory in the project repository means a process change and an instruction change travel in one pull request.
A final rule concerns where your skills come from. Catalogues collecting ready made entries already exist, Skills.sh among them, and installing several dozen at once is tempting. That usually makes things worse, since descriptions start overlapping and the agent picks the wrong one. One person collections circulate alongside the registries, Everything Claude Code among them, where over a hundred skills arrive together with hooks, commands, and working rules. Such a set reads like somebody else's editor configuration: worth mining for ideas, not worth adopting whole, because it reflects its author's habits and a hundred odd similar descriptions make it harder for the agent to pick the right one. Treat somebody else's skills as a starting point to read and adapt rather than a dependency to install and forget. The ones that work best are written for a specific team and a specific repository, because that is exactly the knowledge a model lacks.
Common mistakes
The first is a description from which the use case cannot be recognised. If the agent never reaches for your skill, nine times out of ten the problem lies in the description rather than the instructions.
The second is cramming everything into the main file. The format has three levels precisely so that long material can live in supporting files. The main file should fit on a screen, with details moving into the documentation directory the agent opens when needed.
The third is confusing a skill with a tool. A skill grants the agent no new technical capability: if it lacks database access, no instruction changes that. Access comes from the tool layer; the skill says how to use it.
The fourth is treating bundled scripts as trusted code. A skill pulled from an external catalogue can carry scripts the agent will execute with your permissions. That is the same class of risk as installing a dependency from an unknown source and deserves the same caution.
The fifth is writing skills for things better expressed in code. If a procedure can be captured in a script run by one command, do that and let the skill merely say when to run it. Instructions describing twenty steps a computer would perform deterministically are an invitation to errors.
The full format specification lives at agentskills.io, and work on the standard happens in the project repository.