advanced65 minLesson 7 of 10

Building Custom Agent Skills

Learn to design, implement, test, and deploy custom skills for AI agents with tool integration and resource management.

Building Custom Agent Skills

Skill Development Lifecycle

Building a custom skill follows a structured lifecycle from concept to deployment. Each phase ensures the skill is reliable, testable, and maintainable.

100%
ℹ️Note

The skill development lifecycle is iterative. Each deployment provides feedback that informs the next design iteration. Skills improve over time as edge cases are discovered and handled.


Skill Manifest Structure

Every skill begins with a YAML manifest file that declares its identity, requirements, and behavior.

yaml
# skills/api-client-generator/skill.yaml name: api-client-generator version: "1.0.0" description: | Generates type-safe API client libraries from OpenAPI/Swagger specifications. Supports Python, TypeScript, and Go targets. author: "NUniversity" license: "MIT" instructions: - path: instructions/generate-client.md - path: instructions/validate-spec.md - path: instructions/handle-errors.md tools: required: - read - write - glob optional: - bash - websearch resources: - path: templates/python-client.py.hbs description: Python client template - path: templates/typescript-client.ts.hbs description: TypeScript client template - path: schemas/openapi-v3.json description: OpenAPI 3.0 schema reference - path: configs/lint-rules.yaml description: Generated client linting rules autoload: enabled: true matchPattern: "generate client|api client|openapi|swagger" constraints: maxTokens: 8192 temperature: 0.3 errorRecovery: onFailure: "report_and_stop" maxRetries: 2
💡Tip

Declare tool requirements precisely. Mark tools as required only if the skill cannot function without them. Use optional for tools that enhance results but have fallback behavior. This prevents the skill from failing when optional tools are unavailable.


Writing Effective Instructions

Instructions are the core of a skill. They must be clear, complete, and handle edge cases.

markdown
<!-- skills/api-client-generator/instructions/generate-client.md --> # Generate API Client ## Step 1: Read the OpenAPI Specification - Use the `read` tool to load the OpenAPI/Swagger file - Validate the specification structure: - Must have `openapi` or `swagger` version field - Must have `info.title` and `info.version` - Must have at least one `paths` entry - If validation fails, report specific schema issues ## Step 2: Extract API Metadata - Parse the `info` section for API name and version - Identify the base URL from `servers` or `host` field - Extract security schemes from `components.securitySchemes` ## Step 3: Generate Client Code For each path and method in the specification: 1. Generate the function signature with typed parameters 2. Add request building logic (URL, headers, body) 3. Add response parsing with error handling 4. Generate comprehensive docstrings ## Step 4: Generate Supporting Types - Generate request/response interfaces - Generate enum types for string enums - Generate error types for error responses ## Step 5: Generate Tests - Create unit tests for each client method - Mock the HTTP layer - Test success and error response handling - Test edge cases (empty responses, network errors) ## Step 6: Verify - Run the linter on generated code - Verify TypeScript compilation (or Python import) - Report any generation issues
python
# Programmatic skill implementation import json import os from typing import Optional class ApiClientGeneratorSkill: def __init__(self, agent): self.agent = agent self.language_handlers = { "python": self._generate_python, "typescript": self._generate_typescript, "go": self._generate_go, } async def execute(self, spec_path, language="python", output_dir="generated"): spec_content = await self.agent.read(spec_path) spec = json.loads(spec_content) errors = self._validate_spec(spec) if errors: return {"status": "error", "validation_errors": errors} os.makedirs(output_dir, exist_ok=True) handler = self.language_handlers.get(language) if not handler: return {"status": "error", "message": f"Unsupported language: {language}"} client_code = handler(spec, spec_path) client_path = os.path.join(output_dir, self._client_filename(language)) await self.agent.write(client_path, client_code) test_code = self._generate_tests(spec, language) test_path = os.path.join(output_dir, self._test_filename(language)) await self.agent.write(test_path, test_code) return { "status": "success", "client_file": client_path, "test_file": test_path, "endpoints_generated": len(spec.get("paths", {})) } def _validate_spec(self, spec): errors = [] if "openapi" not in spec and "swagger" not in spec: errors.append("Missing version field (openapi or swagger)") if "info" not in spec: errors.append("Missing info section") elif "title" not in spec.get("info", {}): errors.append("Missing info.title") if "paths" not in spec or not spec["paths"]: errors.append("No paths defined") return errors def _generate_python(self, spec, spec_path): name = spec.get("info", {}).get("title", "ApiClient") lines = [ f'"""Auto-generated client for {name}"""', "import requests", "from typing import Optional, Any, Dict", "", f"class {name.replace(' ', '')}Client:", f' """Client for the {name} API."""', " def __init__(self, base_url, api_key=None):", " self.base_url = base_url.rstrip('/')", " self.session = requests.Session()", " if api_key:", ' self.session.headers["Authorization"] = f"Bearer {api_key}"', "", ] for path, methods in spec.get("paths", {}).items(): for method in methods: func_name = self._path_to_function(method, path) lines.append(f" def {func_name}(self):") lines.append(f' """Call {method.upper()} {path}."""') lines.append(f' url = f"{{self.base_url}}{path}"') lines.append(f" response = self.session.{method}(url)") lines.append(" response.raise_for_status()") lines.append(" return response.json()") lines.append("") return "\n".join(lines) def _generate_typescript(self, spec, spec_path): name = spec.get("info", {}).get("title", "ApiClient") lines = [ f"// Auto-generated client for {name}", "import axios, { AxiosInstance } from 'axios';", "", f"export class {name.replace(' ', '')}Client {{", " private client: AxiosInstance;", " constructor(baseURL: string, apiKey?: string) {", " this.client = axios.create({ baseURL });", " if (apiKey) {", " this.client.defaults.headers.common['Authorization'] = `Bearer ${apiKey}`;", " }", " }", "", ] for path, methods in spec.get("paths", {}).items(): for method in methods: func_name = self._path_to_function(method, path) lines.append(f" async {func_name}(): Promise<any> {{") lines.append(f" const response = await this.client.{method}('{path}');") lines.append(" return response.data;") lines.append(" }") lines.append("") lines.append("}") return "\n".join(lines) def _generate_go(self, spec, spec_path): name = spec.get("info", {}).get("title", "ApiClient") safe_name = name.replace(" ", "") lines = [ f"// Package client provides auto-generated client for {name}", "package client", "", 'import (', ' "net/http"', ' "encoding/json"', ")", "", f"type {safe_name}Client struct {{", " baseURL string", " httpClient *http.Client", " apiKey string", "}", "", f"func New{safe_name}Client(baseURL string, apiKey string) *{safe_name}Client {{", f" return &{safe_name}Client{{", " baseURL: baseURL,", " httpClient: &http.Client{},", " apiKey: apiKey,", " }", "}", "", ] for path, methods in spec.get("paths", {}).items(): for method in methods: func_name = self._path_to_function(method, path) lines.append(f"func (c *{safe_name}Client) {func_name}() (map[string]interface{{}}, error) {{") lines.append(f' url := c.baseURL + "{path}"') lines.append(f' req, _ := http.NewRequest("{method.upper()}", url, nil)') lines.append(' req.Header.Set("Authorization", "Bearer " + c.apiKey)') lines.append(" resp, err := c.httpClient.Do(req)") lines.append(" if err != nil { return nil, err }") lines.append(" defer resp.Body.Close()") lines.append(" var result map[string]interface{}") lines.append(" json.NewDecoder(resp.Body).Decode(&result)") lines.append(" return result, nil") lines.append("}") lines.append("") return "\n".join(lines) def _generate_tests(self, spec, language): if language == "python": name = spec.get("info", {}).get("title", "ApiClient") safe = name.replace(" ", "") lines = [ '"""Tests for auto-generated API client."""', "import pytest", f"from client import {safe}Client", "", "@pytest.fixture", "def client():", f' return {safe}Client("https://api.example.com")', "", ] for path, methods in spec.get("paths", {}).items(): for method in methods: func_name = self._path_to_function(method, path) lines.append(f"def test_{func_name}(client, requests_mock):") url = f"https://api.example.com{path}" lines.append(f' requests_mock.{method}("{url}", json={{"status": "ok"}})') lines.append(f" result = client.{func_name}()") lines.append(' assert result["status"] == "ok"') lines.append("") return "\n".join(lines) return "# Tests not yet implemented for this language" def _path_to_function(self, method, path): parts = path.strip("/").replace("/", "_").replace("-", "_").replace("{", "").replace("}", "") return f"{method}_{parts}" def _client_filename(self, language): return {"python": "client.py", "typescript": "client.ts", "go": "client.go"}[language] def _test_filename(self, language): return {"python": "test_client.py", "typescript": "client.test.ts", "go": "client_test.go"}[language]

Skill Resource Management

Skills can bundle resources like templates, reference files, and configuration.

yaml
# skills/api-client-generator/resources/templates/python-client.py.hbs \"\"\" Auto-generated client for {{info.title}} v{{info.version}} \"\"\" import requests from typing import Optional, Any, Dict, List class {{info.title | replace(' ', '')}}Client: \"\"\"Client for the {{info.title}} API.\"\"\" def __init__(self, base_url, api_key=None): self.base_url = base_url.rstrip('/') self.session = requests.Session() self.session.headers.update({ "Content-Type": "application/json", "Accept": "application/json", }) if api_key: self.session.headers["Authorization"] = f"Bearer {api_key}" {% for path, methods in paths.items() %} {% for method in methods %} def {{method}}_{{path | to_snake}}(self, ...): \"\"\"{{methods[method].summary}}\"\"\" url = f"{self.base_url}{{path}}" response = self.session.{{method}}(url) response.raise_for_status() return response.json() {% endfor %} {% endfor %}
💡Tip

Use Handlebars-style templates (.hbs) for generated code. They allow clean separation of template logic from the skill's Python code and make templates easier to maintain and customize.


Testing Skills

Skills must be tested thoroughly before deployment.

python
import pytest import json from unittest.mock import AsyncMock @pytest.fixture def mock_agent(): agent = AsyncMock() agent.read.return_value = json.dumps({ "openapi": "3.0.0", "info": {"title": "Test API", "version": "1.0.0"}, "paths": { "/users": {"get": {"summary": "List users"}}, "/users/{id}": {"get": {"summary": "Get user"}} } }) return agent @pytest.mark.asyncio async def test_api_client_generator_python(mock_agent): skill = ApiClientGeneratorSkill(mock_agent) result = await skill.execute("spec.yaml", language="python") assert result["status"] == "success" assert result["endpoints_generated"] == 2 @pytest.mark.asyncio async def test_invalid_spec_returns_errors(mock_agent): mock_agent.read.return_value = json.dumps({ "openapi": "3.0.0" }) skill = ApiClientGeneratorSkill(mock_agent) result = await skill.execute("invalid.yaml") assert result["status"] == "error" assert len(result["validation_errors"]) > 0 @pytest.mark.asyncio async def test_unsupported_language(mock_agent): skill = ApiClientGeneratorSkill(mock_agent) result = await skill.execute("spec.yaml", language="rust") assert result["status"] == "error" assert "rust" in result["message"].lower() @pytest.mark.asyncio async def test_generated_code_structure(mock_agent): skill = ApiClientGeneratorSkill(mock_agent) result = await skill.execute("petstore.yaml", language="python") write_args = mock_agent.write.call_args_list[0][0] content = write_args[1] assert "class TestAPIClient:" in content assert "def get_users(self):" in content

Skill Registration in OpenCode

json
{ "skills": { "api-client-generator": { "manifest": "skills/api-client-generator/skill.yaml", "autoLoad": true, "matchPattern": "api client|openapi|swagger|generate client" }, "code-reviewer": { "manifest": "skills/code-reviewer/skill.yaml", "autoLoad": true, "matchPattern": "review|audit|inspect" }, "database-migration": { "manifest": "skills/database-migration/skill.yaml", "autoLoad": false } }, "agents": { "default": { "model": "gpt-4o", "description": "Primary coding assistant", "skills": ["api-client-generator", "code-reviewer"] } } }

Skill Design Patterns

PatternDescriptionBest For
Single PurposeOne skill = one taskLinting, testing, generation
PipelineChain of skillsBuild -> Test -> Deploy
Template-BasedUses templates for outputCode generation, scaffolding
AdaptiveAdjusts behavior based on contextCode review, debugging
InteractiveRequires user input at pointsDeployment, configuration
Sub-SkillOrchestrates other skillsMeta workflows
⚠️Warning

Avoid creating a "God Skill" that tries to do everything. If a skill's instructions span more than 200 lines or it requires more than 5 tools, it should probably be split into smaller, focused skills that can be composed together.


Skill Versioning Strategy

yaml
# versioning-policy.yaml versioning: strategy: "semantic" rules: major: - "Breaking changes to skill interface" - "Removed required tools" - "Changed output format" minor: - "Added new instructions or resources" - "Added optional tool support" - "Enhanced error recovery" patch: - "Fixed instruction typos" - "Improved example code" - "Updated resource references" compatibility: - "Skills with same major version are compatible" - "Minor version upgrades are backward compatible" - "Patch versions require no changes from consumers"

Practice Exercises

Practice Question

What are the three required components of a skill manifest?

Practice Question

When should a tool be marked as 'required' vs 'optional' in a skill manifest?

Practice Question

What is the purpose of the 'autoload' section in a skill manifest?

Practice Question

What testing pattern is recommended for custom skills?

Practice Question

What is a 'God Skill' antipattern?

Practice Question

In the skill development lifecycle, what happens after the deployment phase?

Practice Question

What file format is recommended for code generation templates bundled with skills?

Practice Question

What happens when a skill's matchPattern is too broad?


**Key Takeaways**
  • Skills follow a lifecycle: Design -> Implement -> Test -> Deploy -> Monitor -> Iterate
  • Manifests declare identity, tools, resources, autoload patterns, and constraints
  • Instructions are the core of a skill; they must be clear, complete, and handle edge cases
  • Test skills with mock agents for unit tests and real tools for integration tests
  • Avoid God Skills; follow single responsibility for composable, maintainable skills
  • Use Handlebars templates for generated code in skills
  • Register skills in opencode.json with autoload and matchPattern
  • Use semantic versioning to communicate breaking changes in skills
  • Monitor skill usage to inform iterative improvements
Progress70%