Skill Input/Output Contract Design
Chapter 31: Designing Skill Input/Output Contracts
The quality of a Skill is largely determined by the rigor of its interface design. Poor input validation causes Skills to crash on unexpected inputs; vague output formats prevent callers from reliably processing results. This chapter systematically covers the principles of Skill input/output contract design, helping you build robust, predictable, and evolvable Skill interfaces.
31.1 Input Schema Design Principles
Principle 1: Minimal Required Fields
Only mark parameters as required when they are truly necessary. Excessive required fields reduce usability:
// Overloaded required fields — poor design ❌
{
"type": "object",
"required": ["topic", "time_range", "max_articles", "language", "format", "save_file"],
"properties": { ... }
}
// Minimal required fields — good design ✓
{
"type": "object",
"required": ["topic"],
"properties": {
"topic": {
"type": "string",
"description": "The news topic to research"
},
"time_range": {
"type": "string",
"enum": ["today", "24h", "this_week"],
"default": "today"
},
"max_articles": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5
}
}
}
Principle 2: Precise Type Constraints
Avoid using generic string for finite sets or numeric values:
{
"properties": {
// Wrong: using string for enum ❌
"format": {
"type": "string",
"description": "Output format: prose, bullets, or structured"
},
// Correct: use enum ✓
"format": {
"type": "string",
"enum": ["prose", "bullets", "structured"],
"default": "prose"
},
// Wrong: missing numeric constraints ❌
"timeout_seconds": {"type": "integer"},
// Correct: with range constraints ✓
"timeout_seconds": {
"type": "integer",
"minimum": 5,
"maximum": 120,
"default": 30
}
}
}
Principle 3: Rich Descriptions
The description field directly affects whether an LLM can correctly populate parameters. A good description includes format, examples, and boundary conditions:
{
"date_range": {
"type": "string",
"description": "Date range in ISO 8601 format. Use 'YYYY-MM-DD/YYYY-MM-DD' for a range, or 'YYYY-MM-DD' for a single day. Examples: '2024-11-01/2024-11-30', '2024-11-20'. Maximum range: 365 days.",
"pattern": "^\\d{4}-\\d{2}-\\d{2}(/\\d{4}-\\d{2}-\\d{2})?$",
"examples": ["2024-11-20", "2024-11-01/2024-11-30"]
}
}
Principle 4: Use additionalProperties: false
{
"type": "object",
"required": ["topics"],
"properties": { ... },
"additionalProperties": false // Reject undeclared parameters
}
Complete Input Schema Example
NEWS_DIGEST_INPUT_SCHEMA = {
"$schema": "https://json-schema.org/draft/2020-12",
"type": "object",
"title": "DailyNewsDigestInput",
"required": ["topics"],
"additionalProperties": False,
"properties": {
"topics": {
"type": "array",
"description": "List of news topics. Each should be a specific subject, not a question. Examples: ['AI regulation', 'Tesla stock']",
"items": {"type": "string", "minLength": 2, "maxLength": 100},
"minItems": 1,
"maxItems": 5
},
"time_range": {
"type": "string",
"enum": ["today", "24h", "this_week", "this_month"],
"default": "today"
},
"max_articles_per_topic": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5
},
"output_language": {
"type": "string",
"default": "auto",
"description": "Language code or 'auto' for detection. Examples: 'en', 'zh', 'es', 'auto'",
"pattern": "^(auto|[a-z]{2})$"
},
"output_format": {
"type": "string",
"enum": ["prose", "bullets", "structured", "brief"],
"default": "structured"
},
"save_to_file": {
"type": "boolean",
"default": False
}
}
}
31.2 Output Format Specification
Structured vs. Natural Language Output
| Scenario | Recommended Format | Reason |
|---|---|---|
| Skill called by another Agent | Structured (JSON/YAML) | Machine-parseable, unambiguous |
| Skill output shown to user | Natural language + Markdown | Readable |
| Mixed scenario | Structured Markdown with schema | Best of both |
| Error responses | Always structured | Consistent error handling |
Output Schema
NEWS_DIGEST_OUTPUT_SCHEMA = {
"$schema": "https://json-schema.org/draft/2020-12",
"type": "object",
"title": "DailyNewsDigestOutput",
"required": ["status", "generated_at", "topics_covered"],
"properties": {
"status": {
"type": "string",
"enum": ["success", "partial_success", "failed"]
},
"generated_at": {
"type": "string",
"format": "date-time"
},
"topics_covered": {
"type": "array",
"items": {
"type": "object",
"required": ["topic", "summary", "articles"],
"properties": {
"topic": {"type": "string"},
"summary": {"type": "string"},
"articles": {
"type": "array",
"items": {
"type": "object",
"required": ["title", "source", "url"],
"properties": {
"title": {"type": "string"},
"source": {"type": "string"},
"url": {"type": "string", "format": "uri"},
"published_at": {"type": "string"},
"key_points": {
"type": "array",
"items": {"type": "string"},
"maxItems": 5
}
}
}
}
}
}
},
"errors": {
"type": "array",
"items": {
"type": "object",
"required": ["code", "message", "recoverable"],
"properties": {
"code": {"type": "string"},
"message": {"type": "string"},
"recoverable": {"type": "boolean"},
"suggested_action": {"type": "string"}
}
}
}
}
}
31.3 Parameter Validation and Type Coercion
import jsonschema
from typing import Any
class SkillInputValidator:
def __init__(self, schema: dict):
self.schema = schema
self.validator = jsonschema.Draft202012Validator(
schema,
format_checker=jsonschema.FormatChecker()
)
def validate(self, data: dict) -> tuple[bool, list[str]]:
errors = list(self.validator.iter_errors(data))
if not errors:
return True, []
return False, [
f"{'[' + ' -> '.join(str(p) for p in e.absolute_path) + '] ' if e.absolute_path else ''}{e.message}"
for e in errors
]
def coerce_and_validate(self, data: dict) -> dict:
"""Handle common LLM type errors before validation."""
coerced = self._coerce_types(data)
is_valid, errors = self.validate(coerced)
if not is_valid:
raise SkillInputError("Input validation failed", errors=errors)
return coerced
def _coerce_types(self, data: dict) -> dict:
"""
Handle common LLM mistakes:
- String numbers → int/float
- String booleans → bool
- Single value → single-element array
"""
result = dict(data)
schema_props = self.schema.get("properties", {})
for key, value in data.items():
expected_type = schema_props.get(key, {}).get("type")
if expected_type == "integer" and isinstance(value, str):
try:
result[key] = int(value)
except ValueError:
pass
elif expected_type == "boolean" and isinstance(value, str):
if value.lower() in ("true", "yes", "1"):
result[key] = True
elif value.lower() in ("false", "no", "0"):
result[key] = False
elif expected_type == "array" and not isinstance(value, list):
result[key] = [value]
return result
class SkillInputError(Exception):
def __init__(self, message: str, errors: list[str] = None):
super().__init__(message)
self.errors = errors or []
def to_response(self) -> dict:
return {
"status": "failed",
"errors": [{"code": "INVALID_INPUT", "message": str(self),
"recoverable": True, "details": self.errors}]
}
31.4 Error Response Format Standard
from enum import Enum
from dataclasses import dataclass
from typing import Optional
class ErrorCode(str, Enum):
INVALID_INPUT = "INVALID_INPUT"
MISSING_REQUIRED_PARAM = "MISSING_REQUIRED_PARAM"
TOOL_NOT_AVAILABLE = "TOOL_NOT_AVAILABLE"
TOOL_TIMEOUT = "TOOL_TIMEOUT"
SEARCH_API_ERROR = "SEARCH_API_ERROR"
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
INTERNAL_ERROR = "INTERNAL_ERROR"
@dataclass
class SkillError:
code: ErrorCode
message: str
recoverable: bool
suggested_action: Optional[str] = None
def handle_skill_error(exception: Exception, context: dict) -> dict:
"""Convert exception to standard error response."""
if isinstance(exception, SkillInputError):
error = SkillError(
code=ErrorCode.INVALID_INPUT,
message="Input parameters are invalid",
recoverable=True,
suggested_action="Check parameter types and values"
)
elif isinstance(exception, TimeoutError):
error = SkillError(
code=ErrorCode.TOOL_TIMEOUT,
message=f"Operation timed out after {context.get('timeout', '?')}s",
recoverable=True,
suggested_action="Try again or reduce the number of topics"
)
elif isinstance(exception, RateLimitError):
error = SkillError(
code=ErrorCode.RATE_LIMIT_EXCEEDED,
message="Search API rate limit exceeded",
recoverable=True,
suggested_action=f"Wait {getattr(exception, 'retry_after', 60)}s before retrying"
)
else:
error = SkillError(
code=ErrorCode.INTERNAL_ERROR,
message="An unexpected error occurred",
recoverable=False
)
return {
"status": "failed",
"errors": [{
"code": error.code.value,
"message": error.message,
"recoverable": error.recoverable,
**({"suggested_action": error.suggested_action} if error.suggested_action else {})
}]
}
31.5 Version Compatibility Constraints
Semantic Versioning Rules
MAJOR.MINOR.PATCH
MAJOR — Breaking changes:
- Remove or rename required parameters
- Change required fields in output schema
- Change tool dependencies (remove or swap tools)
Example: 1.0.0 → 2.0.0
MINOR — Backward-compatible additions:
- Add new optional parameters (with reasonable defaults)
- Add new optional fields to output
- Add new enum values
Example: 1.0.0 → 1.1.0
PATCH — Backward-compatible fixes:
- Fix validation logic bugs
- Improve error messages
- Performance optimizations
Example: 1.0.0 → 1.0.1
from packaging.version import Version
from packaging.specifiers import SpecifierSet
def check_skill_compatibility(requirement: str, current: str) -> tuple[bool, str]:
spec = SpecifierSet(requirement)
if Version(current) in spec:
return True, ""
return False, f"Skill requires Hermes {requirement}, but current is {current}"
# Schema migration example: v1 (string topics) → v2 (array topics)
class SkillInputMigration:
@staticmethod
def migrate_from_v1(v1_input: dict) -> dict:
v2 = dict(v1_input)
if isinstance(v1_input.get("topics"), str):
v2["topics"] = [v1_input["topics"]]
return v2
31.6 Complete API Contract (JSON Schema)
{
"$schema": "https://json-schema.org/draft/2020-12",
"title": "Daily News Digest Skill API Contract",
"version": "1.0.0",
"input": {"$ref": "#/definitions/Input"},
"output": {
"oneOf": [
{"$ref": "#/definitions/SuccessResponse"},
{"$ref": "#/definitions/ErrorResponse"}
]
},
"definitions": {
"Input": {
"type": "object",
"required": ["topics"],
"additionalProperties": false,
"properties": {
"topics": {
"type": "array",
"items": {"type": "string", "minLength": 2, "maxLength": 100},
"minItems": 1,
"maxItems": 5
},
"time_range": {
"type": "string",
"enum": ["today", "24h", "this_week", "this_month"],
"default": "today"
},
"max_articles_per_topic": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5
},
"output_language": {"type": "string", "default": "auto"},
"output_format": {
"type": "string",
"enum": ["prose", "bullets", "structured", "brief"],
"default": "structured"
},
"save_to_file": {"type": "boolean", "default": false}
}
},
"SuccessResponse": {
"type": "object",
"required": ["status", "generated_at", "topics_covered"],
"properties": {
"status": {"const": "success"},
"generated_at": {"type": "string", "format": "date-time"},
"topics_covered": {
"type": "array",
"items": {
"required": ["topic", "summary", "articles"],
"properties": {
"topic": {"type": "string"},
"summary": {"type": "string"},
"articles": {
"type": "array",
"items": {
"required": ["title", "source", "url"],
"properties": {
"title": {"type": "string"},
"source": {"type": "string"},
"url": {"type": "string", "format": "uri"},
"published_at": {"type": "string"},
"key_points": {"type": "array", "items": {"type": "string"}}
}
}
}
}
}
}
}
},
"ErrorResponse": {
"type": "object",
"required": ["status", "errors"],
"properties": {
"status": {"enum": ["failed", "partial_success"]},
"errors": {
"type": "array",
"items": {
"required": ["code", "message", "recoverable"],
"properties": {
"code": {"type": "string"},
"message": {"type": "string"},
"recoverable": {"type": "boolean"},
"suggested_action": {"type": "string"}
}
}
}
}
}
}
}
31.7 Summary
Skill input/output contracts are the foundation of interface quality:
- Input schema: Minimal required, precise types, rich descriptions,
additionalProperties: false - Output format: Choose based on caller type; always use standardized error responses
- Validation: Validate before execution with clear error messages; gracefully coerce common LLM type errors
- Versioning: Strict semver — breaking changes require MAJOR bump; backward compatibility is paramount
- API contract: Combine input/output schemas into a single JSON Schema document as the Skill's binding "contract"
Discussion Questions
-
additionalProperties: falserejects all undeclared parameters. In some scenarios, a Skill may need to pass unknown parameters through to underlying tools. How do you balance strict validation with flexible extensibility? -
Friendly type coercion (e.g., string "5" → integer 5) may mask real errors. How do you distinguish "helpful correction" from "tolerating mistakes"? Where should the boundary be?
-
When your Skill upgrades from v1 (topics as string) to v2 (topics as array), what happens to users already on v1 in production? How do you handle this breaking change gracefully on ClawHub?
-
The
descriptionfield must serve both human readers and LLMs, but their needs differ. Humans want complete documentation; LLMs need concise and precise cues. How do you satisfy both in a singledescription?