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:
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:
mkdir claude-skillscd claude-skillsnpm init -yCreating 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:
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:
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:
{ "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 callsconst 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:
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:
npm testReal-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”:
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:
- Always validate inputs: Use Zod schemas to validate parameters before execution
- Handle errors gracefully: Return consistent error structures with helpful messages
- Keep skills focused: One skill should do one thing well, not multiple unrelated tasks
- Document clearly: Provide clear descriptions of what your skill does and its parameters
- Test thoroughly: Skills run in production, so they need to be reliable
- 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