Agent Skills - Rozszerz AI Agenta
Czym są Agent Skills?
Agent Skills to wzorce, narzędzia i możliwości, które pozwalają AI agentom wykonywać specjalistyczne zadania wykraczające poza generowanie tekstu. Gdy model językowy (LLM) ma dostęp do skills/tools, staje się prawdziwym agentem - może wchodzić w interakcję ze światem zewnętrznym: scrapować strony, wykonywać zapytania do bazy danych, manipulować plikami, wysyłać emaile i integrować się z dowolnym API.
Koncepcja skills/tools jest fundamentem nowoczesnych systemów AI i występuje pod różnymi nazwami w zależności od platformy:
- Tool Use (Anthropic Claude)
- Function Calling (OpenAI)
- Tools (LangChain)
- MCP (Model Context Protocol - Anthropic)
- Actions (GPTs)
- Skills (Custom agents)
Ewolucja od chatbotów do agentów
Tradycyjne chatboty mogły tylko generować tekst na podstawie promptu. Nowoczesne AI agenty potrafią:
- Analizować - Zrozumieć zadanie użytkownika
- Planować - Zdecydować, które narzędzia użyć
- Wykonywać - Wywołać odpowiednie skills/tools
- Iterować - Wykorzystać wyniki do dalszych działań
- Raportować - Przedstawić końcowy rezultat
Ten model "ReAct" (Reasoning + Acting) pozwala agentom rozwiązywać kompleksowe, wieloetapowe problemy.
Architektura systemu ze 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 │
└──────────────────────────────────────────────────────────┘Dlaczego Skills są ważne?
1. Przełamanie ograniczeń LLM
Modele językowe same w sobie:
- Nie mają dostępu do internetu w czasie rzeczywistym
- Nie mogą wykonywać kodu
- Nie znają aktualnych danych (knowledge cutoff)
- Nie mogą modyfikować systemów zewnętrznych
Skills rozwiązują te ograniczenia, dając LLM "ręce" do działania w świecie.
2. Specjalizacja i modularność
Zamiast próbować zbudować jeden "super-model" który umie wszystko, skills pozwalają:
- Dodawać specjalistyczne możliwości modułowo
- Aktualizować pojedyncze funkcje bez zmiany całego systemu
- Łączyć różne skills w zależności od potrzeb
- Testować każdy skill niezależnie
3. Bezpieczeństwo i kontrola
Skills działają jako kontrolowany interfejs między AI a światem:
- Możesz ograniczyć, co agent może robić
- Możesz logować i audytować wszystkie akcje
- Możesz dodać rate limiting i sandboxing
- Możesz wymagać approval dla niebezpiecznych operacji
4. Redukcja halucynacji
Gdy LLM ma dostęp do rzeczywistych danych przez skills:
- Nie musi "zgadywać" informacji
- Może zweryfikować fakty przed odpowiedzią
- Odpowiedzi są oparte na aktualnych danych
- Mniejsze ryzyko confabulation
Anatomy of a Skill
Podstawowa struktura skill
interface Skill {
// Identyfikator używany przez LLM do wywołania
name: string
// Opis dla LLM - co robi ten skill
description: string
// Schema parametrów (JSON Schema lub Zod)
inputSchema: JSONSchema | ZodSchema
// Opcjonalny schema outputu
outputSchema?: JSONSchema | ZodSchema
// Główna logika wykonania
execute: (params: unknown) => Promise<unknown>
// Opcjonalne metadane
metadata?: {
category?: string
requiresAuth?: boolean
rateLimit?: number
timeout?: number
}
}Przykład kompletnego skill
import { z } from 'zod'
// Schema parametrów z Zod
const weatherInputSchema = z.object({
city: z.string().describe('Nazwa miasta'),
units: z.enum(['metric', 'imperial']).default('metric').describe('Jednostki temperatury'),
lang: z.string().default('pl').describe('Język odpowiedzi')
})
// Schema outputu
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: `Pobiera aktualną pogodę dla podanego miasta.
Użyj tego narzędzia gdy użytkownik pyta o pogodę, temperaturę,
czy będzie padać, jakie warunki atmosferyczne.`,
inputSchema: weatherInputSchema,
outputSchema: weatherOutputSchema,
metadata: {
category: 'information',
requiresAuth: true, // Wymaga 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
})
}
}Popularne kategorie skills
1. Web & Internet Skills
// Web Scraper - pobieranie treści ze stron
export const webScraperSkill = {
name: 'web_scrape',
description: 'Pobiera i parsuje treść ze strony internetowej',
inputSchema: z.object({
url: z.string().url(),
selector: z.string().optional().describe('CSS selector do wybranych elementów'),
format: z.enum(['text', 'html', 'markdown']).default('text')
}),
async execute({ url, selector, format }) {
// Fetch z headless browser lub prostym fetch
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; Bot/1.0)'
}
})
const html = await response.text()
// Parse HTML
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 {
// Wyciągnij główną treść
const article = document.querySelector('article, main, .content')
content = article?.textContent || document.body.textContent || ''
}
// Format output
if (format === 'markdown') {
return turndownService.turndown(content)
}
return content.trim()
}
}
// Search - wyszukiwanie w internecie
export const searchSkill = {
name: 'web_search',
description: 'Wyszukuje informacje w internecie używając DuckDuckGo/Serper',
inputSchema: z.object({
query: z.string(),
num_results: z.number().min(1).max(10).default(5)
}),
async execute({ query, num_results }) {
// Użyj Serper, SerpAPI, lub DuckDuckGo API
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
// Safe SQL Query
export const databaseQuerySkill = {
name: 'database_query',
description: `Wykonuje bezpieczne zapytanie SQL do bazy danych.
TYLKO SELECT queries są dozwolone. Użyj parametrów dla wartości.`,
inputSchema: z.object({
query: z.string().describe('SQL SELECT query'),
params: z.array(z.any()).default([]).describe('Parametry do query')
}),
async execute({ query, params }) {
// Walidacja - tylko SELECT
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery.startsWith('select')) {
throw new Error('Only SELECT queries are allowed')
}
// Blacklist niebezpiecznych słów kluczowych
const forbidden = ['drop', 'delete', 'update', 'insert', 'alter', 'create', 'truncate']
for (const word of forbidden) {
if (normalizedQuery.includes(word)) {
throw new Error(`Forbidden keyword: ${word}`)
}
}
// Wykonaj query z parametrami (prevents SQL injection)
const result = await db.query(query, params)
// Ogranicz ilość wyników
return result.rows.slice(0, 100)
}
}
// Prisma Query (type-safe)
export const prismaSkill = {
name: 'prisma_query',
description: 'Wykonuje type-safe query przez 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
// Bezpieczne operacje na plikach
export const fileSystemSkill = {
name: 'filesystem',
description: 'Operacje na systemie plików w sandboxowanym katalogu',
inputSchema: z.object({
action: z.enum(['read', 'write', 'list', 'exists', 'mkdir', 'delete']),
path: z.string(),
content: z.string().optional()
}),
metadata: {
// Sandbox do określonego katalogu
sandboxPath: '/workspace',
maxFileSize: 10 * 1024 * 1024 // 10MB
},
async execute({ action, path, content }) {
// Walidacja ścieżki - nie pozwalaj na wyjście z sandbox
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) } // Limit size
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
// JavaScript/TypeScript execution (sandboxed)
export const codeExecutionSkill = {
name: 'execute_code',
description: 'Wykonuje kod JavaScript w bezpiecznym 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 }) {
// Użyj VM2 lub isolated-vm dla bezpieczeństwa
const vm = new NodeVM({
timeout,
sandbox: {
console: {
log: (...args: any[]) => logs.push(args.join(' ')),
error: (...args: any[]) => errors.push(args.join(' '))
}
},
require: {
external: false, // Nie pozwalaj na require
builtin: ['util', 'path'] // Tylko bezpieczne moduły
}
})
const logs: string[] = []
const errors: string[] = []
try {
// Dla TypeScript - transpiluj najpierw
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
}
}
}
}
// Python execution (via subprocess)
export const pythonSkill = {
name: 'execute_python',
description: 'Wykonuje kod Python',
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
// Generic API caller
export const apiCallerSkill = {
name: 'api_call',
description: 'Wykonuje HTTP request do zewnętrznego 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 }) {
// Whitelist dozwolonych domen (security)
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)
}
}
}
// GitHub API skill
export const githubSkill = {
name: 'github',
description: 'Interakcja z 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
})
// Decode base64 content
if ('content' in content.data) {
return {
...content.data,
decoded: Buffer.from(content.data.content, 'base64').toString()
}
}
return content.data
}
}
}6. Communication Skills
// Email sending skill
export const emailSkill = {
name: 'send_email',
description: 'Wysyła email przez 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, // Wymaga potwierdzenia użytkownika
rateLimit: 10 // Max 10 emails/hour
},
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 }
}
}
// Slack messaging skill
export const slackSkill = {
name: 'slack_message',
description: 'Wysyła wiadomość na 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
Implementacja registry
// skills/registry.ts
import { Skill } from './types'
class SkillsRegistry {
private skills: Map<string, Skill> = new Map()
private categories: Map<string, Skill[]> = new Map()
register(skill: Skill) {
// Walidacja skill
this.validateSkill(skill)
// Rejestracja
this.skills.set(skill.name, skill)
// Kategoryzacja
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) || []
}
// Format dla LLM (tool definitions)
toToolDefinitions() {
return this.getAll().map(skill => ({
name: skill.name,
description: skill.description,
input_schema: this.zodToJsonSchema(skill.inputSchema)
}))
}
private zodToJsonSchema(schema: z.ZodSchema) {
// Konwertuj Zod schema do JSON Schema
return zodToJsonSchema(schema)
}
async execute(name: string, params: unknown) {
const skill = this.get(name)
if (!skill) {
throw new Error(`Skill not found: ${name}`)
}
// Rate limiting
if (skill.metadata?.rateLimit) {
await this.checkRateLimit(name, skill.metadata.rateLimit)
}
// Timeout
const timeout = skill.metadata?.timeout || 30000
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error(`Skill ${name} timed out`)), timeout)
})
// Execute with timeout
try {
const result = await Promise.race([
skill.execute(params),
timeoutPromise
])
// Log execution
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) {
// Implementacja rate limiting (np. z Redis)
const key = `ratelimit:${skillName}`
const count = await redis.incr(key)
if (count === 1) {
await redis.expire(key, 60) // 1 minute window
}
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 {
// Usuń wrażliwe dane przed logowaniem
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
}
}
// Singleton instance
export const skillsRegistry = new SkillsRegistry()
// Rejestracja skills
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)Integracja z Claude
Tool Use API
import Anthropic from '@anthropic-ai/sdk'
import { skillsRegistry } from './skills/registry'
const anthropic = new Anthropic()
async function runAgentWithTools(userMessage: string) {
// Pobierz definicje narzędzi
const tools = skillsRegistry.toToolDefinitions()
// Pierwszy request do Claude
let response = await anthropic.messages.create({
model: 'claude-sonnet-4-5-20241022',
max_tokens: 4096,
system: `Jesteś pomocnym asystentem z dostępem do narzędzi.
Używaj narzędzi gdy potrzebujesz aktualnych informacji
lub wykonać akcję. Odpowiadaj po polsku.`,
tools,
messages: [{ role: 'user', content: userMessage }]
})
// Pętla obsługi tool use
while (response.stop_reason === 'tool_use') {
const toolUseBlocks = response.content.filter(
block => block.type === 'tool_use'
)
// Wykonaj wszystkie tool calls
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
}
}
})
)
// Kontynuuj konwersację z wynikami
response = await anthropic.messages.create({
model: 'claude-sonnet-4-5-20241022',
max_tokens: 4096,
system: `Jesteś pomocnym asystentem z dostępem do narzędzi.`,
tools,
messages: [
{ role: 'user', content: userMessage },
{ role: 'assistant', content: response.content },
{ role: 'user', content: toolResults.filter(Boolean) as any }
]
})
}
// Zwróć finalną odpowiedź
const textBlock = response.content.find(block => block.type === 'text')
return textBlock?.type === 'text' ? textBlock.text : ''
}
// Użycie
const answer = await runAgentWithTools(
'Jaka jest pogoda w Warszawie i ile mam otwartych issues na repo example/test?'
)MCP (Model Context Protocol)
MCP to nowszy standard od Anthropic dla integracji narzędzi:
// mcp-server/index.ts
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: {} } }
)
// Rejestracja tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'get_weather',
description: 'Pobiera aktualną pogodę dla miasta',
inputSchema: {
type: 'object',
properties: {
city: { type: 'string', description: 'Nazwa miasta' }
},
required: ['city']
}
}
]
}
})
// Handler dla tool calls
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}`)
}
})
// Start server
const transport = new StdioServerTransport()
await server.connect(transport)Best Practices
1. Security First
// Zawsze waliduj input
async execute(params) {
// Zod schema validation
const validated = inputSchema.parse(params)
// Sanitize user input
validated.query = sanitizeHtml(validated.query)
// Check permissions
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) {
// Log full error internally
console.error('Skill error:', error)
// Return sanitized error to LLM
return {
success: false,
error: error instanceof Error
? error.message
: 'An error occurred'
}
}
}3. Idempotency
// Dla skills które modyfikują stan, używaj idempotency keys
async execute({ action, data, idempotencyKey }) {
// Sprawdź czy już wykonano
const existing = await cache.get(`idempotent:${idempotencyKey}`)
if (existing) {
return existing // Zwróć cached result
}
// Wykonaj akcję
const result = await performAction(data)
// Cache result
await cache.set(`idempotent:${idempotencyKey}`, result, 3600)
return result
}4. Logging & Monitoring
// Structured logging dla każdego skill execution
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'), // 10 requests per minute
})
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...
}Testowanie Skills
import { describe, it, expect, vi } from 'vitest'
import { weatherSkill } from './skills/weather'
describe('weatherSkill', () => {
it('should return weather data for valid city', async () => {
// Mock fetch
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 }) // number instead of string
).rejects.toThrow()
})
})Cennik
| Komponent | Koszt |
|---|---|
| Skill development | Czas developera |
| Claude API (tool use) | Standardowe API rates |
| MCP Server hosting | Zależy od infrastruktury |
| Third-party APIs | Zależy od dostawcy |
Skills są kodem - nie ma dodatkowych opłat za ich używanie. Koszty to:
- API calls do LLM (Claude, OpenAI)
- Hosting infrastruktury (serwery, bazy danych)
- Third-party services (weather API, GitHub, etc.)
FAQ - Najczęściej zadawane pytania
Ile skills może mieć agent?
Teoretycznie nieograniczenie, ale praktycznie:
- Claude wspiera do 64 tools w jednym request
- Więcej tools = więcej tokenów na opisy
- LLM lepiej radzi sobie z mniejszą liczbą dobrze opisanych skills
Jak wybrać między Tool Use a MCP?
- Tool Use API: Prostsze, direct integration, mniej setup
- MCP: Standard protokół, lepsze tooling, reusable servers
Dla prostych przypadków użyj Tool Use. Dla złożonych systemów z wieloma integracjami rozważ MCP.
Czy skills mogą wywoływać inne skills?
Tak, ale z ostrożnością. Lepsze podejście:
- LLM decyduje o sekwencji skills
- Lub użyj "orchestrator" skill który koordynuje inne
Jak obsłużyć długie operacje?
// Użyj async patterns
async execute(params) {
// Start job
const jobId = await startLongJob(params)
// Return job ID, let LLM check status later
return {
status: 'started',
jobId,
checkStatusWith: 'check_job_status'
}
}Jak zabezpieczyć wrażliwe skills?
- Approval workflow - Niektóre skills wymagają potwierdzenia użytkownika
- Scoped permissions - Różni użytkownicy mają dostęp do różnych skills
- Audit logging - Loguj wszystkie wywołania
- Rate limiting - Ogranicz częstotliwość
Czy mogę używać skills z różnymi LLM?
Tak, ale format może się różnić:
- Claude: Tool Use API / MCP
- OpenAI: Function Calling
- LangChain: Tools (abstrakcja nad różnymi modelami)
Warto zbudować abstrakcję która wspiera wiele providers.
Agent Skills - extend your AI agent
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-sonnet-4-5-20241022',
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-sonnet-4-5-20241022',
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?
Theoretically unlimited, but in practice:
- Claude supports up to 64 tools in a single request
- More tools = more tokens for descriptions
- LLMs perform better with fewer well-described skills
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, but the format may differ:
- Claude: Tool Use API / MCP
- OpenAI: Function Calling
- LangChain: Tools (abstraction over different models)
It is worth building an abstraction that supports multiple providers.