> ## Documentation Index > Fetch the complete documentation index at: https://openrouter.ai/docs/llms.txt > Use this file to discover all available pages before exploring further. # Skills Loader > A complete implementation of a skills system similar to Claude Code, demonstrating the power of `nextTurnParams` for context injection. ## Overview This example shows how to build encapsulated, self-managing tools that inject domain-specific context into conversations. When a skill is loaded, it automatically enriches subsequent turns with specialized instructions. ## Prerequisites ```bash title="npm" lines theme={null} npm install @openrouter/agent zod ``` ```bash title="pnpm" lines theme={null} pnpm add @openrouter/agent zod ``` ```bash title="yarn" lines theme={null} yarn add @openrouter/agent zod ``` ```bash title="bun" lines theme={null} bun add @openrouter/agent zod ``` ```bash title="deno" lines theme={null} deno add npm:@openrouter/agent npm:zod ``` Create a skills directory: ```bash lines theme={null} mkdir -p ~/.claude/skills/pdf-processing mkdir -p ~/.claude/skills/data-analysis mkdir -p ~/.claude/skills/code-review ``` ## Basic Skills Tool ```typescript expandable lines theme={null} import { OpenRouter, tool } from '@openrouter/agent'; import { readFileSync, existsSync, readdirSync } from 'fs'; import path from 'path'; import { z } from 'zod'; const openrouter = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY, }); const SKILLS_DIR = path.join(process.env.HOME || '~', '.claude', 'skills'); // List available skills const listAvailableSkills = (): string[] => { if (!existsSync(SKILLS_DIR)) return []; return readdirSync(SKILLS_DIR, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) .filter((dirent) => existsSync(path.join(SKILLS_DIR, dirent.name, 'SKILL.md'))) .map((dirent) => dirent.name); }; const skillsTool = tool({ name: 'Skill', description: `Load a specialized skill to enhance the assistant's capabilities. Available skills: ${listAvailableSkills().join(', ') || 'none configured'} Each skill provides domain-specific instructions and capabilities.`, inputSchema: z.object({ type: z.string().describe("The skill type to load (e.g., 'pdf-processing')"), }), outputSchema: z.string(), // This is where the magic happens - modify context for next turn nextTurnParams: { input: (params, context) => { // Prevent duplicate skill loading const skillMarker = `[Skill: ${params.type}]`; if (JSON.stringify(context.input).includes(skillMarker)) { return context.input; } // Load the skill's instructions const skillPath = path.join(SKILLS_DIR, params.type, 'SKILL.md'); if (!existsSync(skillPath)) { return context.input; } const skill = readFileSync(skillPath, 'utf-8'); const skillDir = path.join(SKILLS_DIR, params.type); // Inject skill context into the conversation const currentInput = Array.isArray(context.input) ? context.input : [context.input]; return [ ...currentInput, { role: 'user', content: `${skillMarker} Base directory for this skill: ${skillDir} ${skill}`, }, ]; }, }, execute: async (params, context) => { const skillMarker = `[Skill: ${params.type}]`; // Check if already loaded if (JSON.stringify(context?.turnRequest?.input || []).includes(skillMarker)) { return `Skill ${params.type} is already loaded`; } const skillPath = path.join(SKILLS_DIR, params.type, 'SKILL.md'); if (!existsSync(skillPath)) { const available = listAvailableSkills(); return `Skill "${params.type}" not found. Available skills: ${available.join(', ') || 'none'}`; } return `Launching skill ${params.type}`; }, }); ``` ## Usage ```typescript lines theme={null} const result = openrouter.callModel({ model: 'anthropic/claude-sonnet-4.5', input: 'I need to process a PDF and extract tables from it', tools: [skillsTool], }); const text = await result.getText(); // The model will call the Skill tool, loading pdf-processing context // Subsequent responses will have access to the skill's instructions ``` ## Example Skill File Create `~/.claude/skills/pdf-processing/SKILL.md`: ```markdown expandable lines theme={null} # PDF Processing Skill You are now equipped with PDF processing capabilities. ## Available Tools When processing PDFs, you have access to: - `extract_text`: Extract all text from a PDF - `extract_tables`: Extract tables as structured data - `extract_images`: Extract embedded images - `split_pdf`: Split PDF into individual pages ## Best Practices 1. Always check PDF file size before processing 2. For large PDFs (>50 pages), process in chunks 3. OCR may be needed for scanned documents 4. Tables may span multiple pages - handle accordingly ## Output Formats - Text: Plain text or markdown - Tables: JSON, CSV, or markdown tables - Images: PNG with sequential naming ## Error Handling - If a PDF is encrypted, request the password - If OCR fails, suggest alternative approaches - Report page numbers for any extraction errors ``` ## Extended: Multi-Skill Loader Load multiple skills in a single call: ```typescript expandable lines theme={null} const multiSkillLoader = tool({ name: 'load_skills', description: 'Load multiple skills at once for complex tasks', inputSchema: z.object({ skills: z.array(z.string()).describe('Array of skill names to load'), }), outputSchema: z.object({ loaded: z.array(z.string()), failed: z.array( z.object({ name: z.string(), reason: z.string(), }) ), }), nextTurnParams: { input: (params, context) => { let newInput = Array.isArray(context.input) ? context.input : [context.input]; for (const skillName of params.skills) { const skillMarker = `[Skill: ${skillName}]`; // Skip if already loaded if (JSON.stringify(newInput).includes(skillMarker)) { continue; } const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md'); if (!existsSync(skillPath)) { continue; } const skillContent = readFileSync(skillPath, 'utf-8'); const skillDir = path.join(SKILLS_DIR, skillName); newInput = [ ...newInput, { role: 'user', content: `${skillMarker} Base directory: ${skillDir} ${skillContent}`, }, ]; } return newInput; }, }, execute: async ({ skills }) => { const loaded: string[] = []; const failed: Array<{ name: string; reason: string }> = []; for (const skill of skills) { const skillPath = path.join(SKILLS_DIR, skill, 'SKILL.md'); if (existsSync(skillPath)) { loaded.push(skill); } else { failed.push({ name: skill, reason: 'Skill not found' }); } } return { loaded, failed }; }, }); // Usage const result = openrouter.callModel({ model: 'anthropic/claude-sonnet-4.5', input: 'I need to analyze a PDF report and create visualizations', tools: [multiSkillLoader], }); // Model might call: load_skills({ skills: ['pdf-processing', 'data-analysis'] }) ``` ## Extended: Skill with Options Skills that accept configuration: ```typescript expandable lines theme={null} const configurableSkillLoader = tool({ name: 'configure_skill', description: 'Load a skill with custom configuration options', inputSchema: z.object({ skillName: z.string(), options: z .object({ verbosity: z.enum(['minimal', 'normal', 'detailed']).default('normal'), strictMode: z.boolean().default(false), outputFormat: z.enum(['json', 'markdown', 'plain']).default('markdown'), }) .optional(), }), outputSchema: z.object({ status: z.enum(['loaded', 'already_loaded', 'not_found']), message: z.string(), configuration: z.record(z.unknown()).optional(), }), nextTurnParams: { input: (params, context) => { const skillMarker = `[Skill: ${params.skillName}]`; if (JSON.stringify(context.input).includes(skillMarker)) { return context.input; } const skillPath = path.join(SKILLS_DIR, params.skillName, 'SKILL.md'); if (!existsSync(skillPath)) { return context.input; } const skillContent = readFileSync(skillPath, 'utf-8'); const options = params.options || {}; // Build configuration header const configHeader = ` ## Skill Configuration - Verbosity: ${options.verbosity || 'normal'} - Strict Mode: ${options.strictMode || false} - Output Format: ${options.outputFormat || 'markdown'} `; const currentInput = Array.isArray(context.input) ? context.input : [context.input]; return [ ...currentInput, { role: 'user', content: `${skillMarker} ${configHeader} ${skillContent}`, }, ]; }, // Adjust model behavior based on skill temperature: (params, context) => { // Lower temperature for strict mode if (params.options?.strictMode) { return 0.3; } return context.temperature; }, }, execute: async ({ skillName, options }) => { const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md'); if (!existsSync(skillPath)) { return { status: 'not_found' as const, message: `Skill "${skillName}" not found`, }; } return { status: 'loaded' as const, message: `Skill "${skillName}" loaded with configuration`, configuration: options || {}, }; }, }); ``` ## Skill Discovery Tool List and describe available skills: ```typescript expandable lines theme={null} const skillDiscoveryTool = tool({ name: 'list_skills', description: 'List all available skills with their descriptions', inputSchema: z.object({ category: z.string().optional().describe('Filter by category'), }), outputSchema: z.object({ skills: z.array( z.object({ name: z.string(), description: z.string(), hasConfig: z.boolean(), }) ), totalCount: z.number(), }), execute: async ({ category }) => { const availableSkills = listAvailableSkills(); const skills = []; for (const skillName of availableSkills) { const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md'); const content = readFileSync(skillPath, 'utf-8'); // Extract first paragraph as description const lines = content.split('\n').filter((l) => l.trim()); const description = lines.find((l) => !l.startsWith('#')) || 'No description'; // Check for config file const configPath = path.join(SKILLS_DIR, skillName, 'config.json'); const hasConfig = existsSync(configPath); skills.push({ name: skillName, description: description.slice(0, 100), hasConfig, }); } return { skills, totalCount: skills.length, }; }, }); ``` ## Complete Example Putting it all together: ```typescript expandable lines theme={null} import { OpenRouter, tool, stepCountIs } from '@openrouter/agent'; import { readFileSync, existsSync, readdirSync } from 'fs'; import path from 'path'; import { z } from 'zod'; const openrouter = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY, }); const SKILLS_DIR = path.join(process.env.HOME || '~', '.claude', 'skills'); // ... (include skillsTool, multiSkillLoader, skillDiscoveryTool from above) // Use all skill tools together const result = openrouter.callModel({ model: 'anthropic/claude-sonnet-4.5', input: `I have a complex task: 1. First, show me what skills are available 2. Load the appropriate skills for PDF analysis 3. Then help me extract and analyze data from report.pdf`, tools: [skillDiscoveryTool, skillsTool, multiSkillLoader], stopWhen: stepCountIs(10), }); const text = await result.getText(); console.log(text); ``` ## Key Patterns ### 1. Idempotency Always check if a skill is already loaded: ```typescript lines theme={null} nextTurnParams: { input: (params, context) => { const marker = `[Skill: ${params.type}]`; if (JSON.stringify(context.input).includes(marker)) { return context.input; // Don't add again } // ... add skill }, }, ``` ### 2. Graceful Fallbacks Handle missing skills gracefully: ```typescript lines theme={null} execute: async (params) => { if (!existsSync(skillPath)) { return `Skill not found. Available: ${listAvailableSkills().join(', ')}`; } // ... }, ``` ### 3. Context Preservation Always preserve existing input: ```typescript lines theme={null} nextTurnParams: { input: (params, context) => { const currentInput = Array.isArray(context.input) ? context.input : [context.input]; return [...currentInput, newMessage]; // Append, don't replace }, }, ``` ### 4. Clear Markers Use unique markers to identify injected content: ```typescript lines theme={null} const skillMarker = `[Skill: ${params.type}]`; // Makes detection reliable and content clearly labeled ``` ## See Also * **[nextTurnParams Guide](/docs/agent-sdk/call-model/next-turn-params)** - Context injection patterns * **[Dynamic Parameters](/docs/agent-sdk/call-model/dynamic-parameters)** - Adaptive behavior * **[Tools](/docs/agent-sdk/call-model/tools)** - Multi-turn orchestration