Skip to content

How to Create a Custom Skill for Claude Code

Purpose

When I first started exploring Claude Code’s skill system, I was overwhelmed. The concept of creating custom skills seemed complex, and I wasn’t sure where to begin. After diving into Anthropic’s 32-page detailed guide and experimenting with several implementations, I realized that custom skills are actually straightforward when you understand the fundamental structure.

In this post, I’ll show you exactly how to create a custom skill for Claude Code based on real-world experience. I’ll share what didn’t work, what I learned through trial and error, and practical examples that actually work.

Setting Up Your Development Environment

Before you start creating skills, you need the right environment. I made the mistake of trying to jump straight into coding without proper setup, which led to frustrating errors later.

First, ensure you have Node.js installed. Skills run in a Node.js environment, so you’ll need:

Terminal window
node --version # Should be 18+
npm --version # Should be 8+

Then, create a dedicated workspace for your skills. I recommend keeping skills separate from your main projects to avoid conflicts:

Terminal window
mkdir claude-skills
cd claude-skills
npm init -y

Creating Your First Skill - Step by Step

Let’s build a simple but practical skill that fetches and analyzes GitHub repositories. This example demonstrates the core concepts while being useful enough to actually use.

Step 1: Skill Definition

The heart of any skill is its definition. This is where you specify what the skill does, what parameters it accepts, and what it returns. I initially tried to skip this step and went straight to implementation - bad idea. The definition acts as a contract between Claude and your skill.

Create a file called index.ts:

src/index.ts
import { z } from 'zod';
const GitHubRepoSchema = z.object({
name: z.string().describe("Repository name"),
description: z.string().optional().describe("Repository description"),
stars: z.number().describe("Number of stars"),
language: z.string().optional().describe("Primary programming language"),
lastUpdated: z.string().describe("Last update date")
});
const GitHubSearchParams = z.object({
query: z.string().min(1).describe("Search query for repositories"),
maxResults: z.number().min(1).max(100).default(10).describe("Maximum number of results"),
sortBy: z.enum(["stars", "forks", "updated"]).default("stars").describe("Sort criteria")
});
export const definition = {
name: "github-repo-analyzer",
description: "Search and analyze GitHub repositories with detailed information",
parameters: GitHubSearchParams,
returns: z.array(GitHubRepoSchema)
};

Why this approach works: Using Zod schemas ensures your inputs are validated before execution. I learned this the hard way when I tried to handle invalid parameters manually - it was a mess of try-catch blocks and error checking that could have been prevented by schema validation.

Step 2: Implementation File

Now for the actual implementation. I made the mistake of putting everything in one file initially, which made it hard to test and maintain. Keep your implementation separate from your definition:

src/github-analyzer.ts
import { z } from 'zod';
interface GitHubRepo {
name: string;
description?: string;
stars: number;
language?: string;
lastUpdated: string;
}
interface GitHubSearchParams {
query: string;
maxResults: number;
sortBy: "stars" | "forks" | "updated";
}
async function fetchGitHubRepos(params: GitHubSearchParams): Promise<GitHubRepo[]> {
const { query, maxResults, sortBy } = params;
// GitHub search API endpoint
const apiUrl = `https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&per_page=${maxResults}&sort=${sortBy}`;
try {
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
// Transform API response to our schema
return data.items.map((repo: any) => ({
name: repo.full_name,
description: repo.description,
stars: repo.stargazers_count,
language: repo.language,
lastUpdated: repo.updated_at
}));
} catch (error) {
console.error('Error fetching GitHub repos:', error);
throw new Error(`Failed to fetch repositories: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function main(params: {
query: string;
maxResults: number;
sortBy: "stars" | "forks" | "updated";
}) {
try {
const results = await fetchGitHubRepos(params);
return {
success: true,
data: results,
meta: {
total: results.length,
query: params.query,
sortBy: params.sortBy
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
meta: {
query: params.query,
sortBy: params.sortBy
}
};
}
}

What I learned the hard way: The main function should always return a consistent structure. I initially tried returning different types based on success/failure, which caused Claude to get confused. The success/error pattern with meta information works much better.

Step 3: Metadata Configuration

This is where I spent the most time debugging. The manifest file is crucial - it tells Claude how to use your skill. I initially omitted several required fields and got cryptic errors.

Create manifest.json:

{
"name": "github-repo-analyzer",
"description": "Search and analyze GitHub repositories with detailed information",
"author": "cowrie",
"version": "1.0.0",
"repository": "https://github.com/yourusername/claude-skills",
"license": "MIT",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "jest"
},
"dependencies": {
"zod": "^3.22.0",
"@types/node": "^20.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"jest": "^29.0.0"
},
"keywords": [
"claude",
"skill",
"github",
"repositories",
"analysis"
]
}

Build configuration: Don’t skip the TypeScript config. I initially tried to run TypeScript files directly, which failed because Claude expects compiled JavaScript:

tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

Advanced Skill Features

Once you have the basics working, you can start adding more advanced features. I found that complex parameters and proper error handling make skills much more robust.

Complex Parameters

Sometimes you need more than simple types. Here’s how to handle nested objects and arrays:

const ComplexSearchParams = z.object({
query: z.string(),
filters: z.object({
language: z.string().optional(),
minStars: z.number().optional(),
topics: z.array(z.string()).optional()
}),
pagination: z.object({
page: z.number().min(1).default(1),
perPage: z.number().min(1).max(100).default(10)
})
});

Why this matters: Complex parameters make your skills more flexible. I initially created a skill that could only handle simple search terms, but once I added nested objects, it became much more powerful.

Performance Optimization

Skills can get slow if you’re not careful. Here are optimizations I learned through trial and error:

// Cache results to avoid repeated API calls
const searchCache = new Map<string, { data: any; timestamp: number }>();
async function cachedSearch(params: any, cacheKey: string, ttl = 300000) {
// Check cache first
const cached = searchCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data;
}
// Fetch fresh data
const data = await fetchGitHubRepos(params);
// Store in cache
searchCache.set(cacheKey, { data, timestamp: Date.now() });
return data;
}

Important: Don’t cache data that changes frequently. I learned this when I cached repository data for too long and served stale information to users.

Testing Your Skill

Testing is crucial. I made the mistake of skipping tests and discovered bugs only after the skill was in use. Here’s a simple test structure:

src/github-analyzer.test.ts
import { main } from './github-analyzer';
describe('GitHub Repo Analyzer', () => {
test('should search repositories successfully', async () => {
const params = {
query: 'react typescript',
maxResults: 5,
sortBy: 'stars' as const
};
const result = await main(params);
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(result.data.length).toBeGreaterThan(0);
expect(result.data[0]).toHaveProperty('name');
expect(result.data[0]).toHaveProperty('stars');
});
test('should handle API errors gracefully', async () => {
const params = {
query: 'invalid-query-that-should-return-nothing',
maxResults: 5,
sortBy: 'stars' as const
};
const result = await main(params);
// Even with no results, it should succeed
expect(result.success).toBe(true);
expect(result.data).toEqual([]);
});
});

Run tests with:

Terminal window
npm test

Real-World Examples

Let’s look at a more complex skill I built for analyzing Reddit discussions, inspired by the Reddit comment about creating “more detailed skill-creator skill”:

src/reddit-analyzer.ts
import { z } from 'zod';
const RedditPostSchema = z.object({
title: z.string(),
url: z.string(),
score: z.number(),
commentCount: z.number(),
author: z.string(),
created: z.string(),
subreddit: z.string(),
isSelfPost: z.boolean(),
content: z.string().optional()
});
const RedditSearchParams = z.object({
subreddit: z.string().optional(),
query: z.string().optional(),
timeFilter: z.enum(["hour", "day", "week", "month", "year", "all"]).default("week"),
sortBy: z.enum(["hot", "new", "top", "rising"]).default("hot"),
limit: z.number().min(1).max(100).default(10)
});
export async function main(params: z.infer<typeof RedditSearchParams>) {
const { subreddit, query, timeFilter, sortBy, limit } = params;
try {
let url = `https://www.reddit.com/r/${subreddit || 'all'}/${sortBy}.json?limit=${limit}`;
if (timeFilter !== "all") {
url += `&t=${timeFilter}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Reddit API error: ${response.status}`);
}
const data = await response.json();
const posts = data.data.children.map((child: any) => ({
title: child.data.title,
url: `https://reddit.com${child.data.permalink}`,
score: child.data.score,
commentCount: child.data.num_comments,
author: child.data.author,
created: new Date(child.data.created_utc * 1000).toISOString(),
subreddit: child.data.subreddit,
isSelfPost: child.data.is_self,
content: child.data.selftext
}));
// Filter by query if provided
const filteredPosts = query
? posts.filter(post =>
post.title.toLowerCase().includes(query.toLowerCase()) ||
(post.content && post.content.toLowerCase().includes(query.toLowerCase()))
)
: posts;
return {
success: true,
data: filteredPosts,
meta: {
total: filteredPosts.length,
subreddit,
query,
timeFilter,
sortBy
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch Reddit data',
meta: {
subreddit,
query,
timeFilter,
sortBy
}
};
}
}

Best Practices from Anthropic’s Guide

After studying Anthropic’s 32-page guide, here are the key takeaways:

  1. Always validate inputs: Use Zod schemas to validate parameters before execution
  2. Handle errors gracefully: Return consistent error structures with helpful messages
  3. Keep skills focused: One skill should do one thing well, not multiple unrelated tasks
  4. Document clearly: Provide clear descriptions of what your skill does and its parameters
  5. Test thoroughly: Skills run in production, so they need to be reliable
  6. Use proper typing: TypeScript helps prevent runtime errors

Common Pitfalls to Avoid

I made these mistakes, so you don’t have to:

  • Skipping error handling: Skills that fail unexpectedly break the user experience
  • Hardcoding values: Use parameters instead of fixed values
  • Ignoring performance: Skills can timeout if they’re too slow
  • Poor documentation: If Claude doesn’t understand what your skill does, it won’t use it
  • Skipping tests: Skills run in production, so they need to be reliable

Conclusion

Creating custom skills for Claude Code is straightforward when you follow the right structure. Start with a clear definition, implement robust error handling, test thoroughly, and document well.

The most important lesson I learned is that skills are just specialized tools - they follow the same software engineering principles as any other code. The difference is that they need to work reliably in a conversational context.

When I built my first skill, I started simple and gradually added complexity. This approach worked much better than trying to build everything at once.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments