Validating Claude Skill Inputs: A Contract-First Pattern
Build Claude skills that fail loudly on missing inputs and silently default safe ones. A contract-first validation pattern with required-vs-optional fields and null-safe defaults.
Validating Claude Skill Inputs: A Contract-First Pattern
A Claude skill is only as reliable as its input contract. The SKILL.md file declares what the skill expects, but nothing enforces that contract at invocation time unless you write the enforcement yourself. Skip it and you get the worst kind of failure: the skill runs, produces plausible-looking output, and ships garbage downstream because a required field was silently absent.
The pattern below is what production skills converge on after a few painful iterations. It treats SKILL.md as the source of truth, splits inputs into required versus optional, fails with information on the required side, and applies null-safe defaults on the optional side.
The contract lives in SKILL.md, but the skill must enforce it
Every skill ships with a SKILL.md file that declares trigger conditions, expected inputs, and output shape. The Anthropic skill spec \u2014 see the Claude skill authoring docs \u2014 treats this file as a hint to the model, not a runtime schema.
That means at invocation time, the skill body sees a free-form <user_context> blob. There is no automatic Pydantic-style rejection. If the caller forgets KEYWORD, the skill will happily hallucinate one.
The fix is a two-line preamble at the top of every skill body:
- List required inputs by name.
- Require the skill to emit a single fail-with-info message if any are missing \u2014 and to refuse to produce its normal output until the caller fixes the call.
## Required Inputs
The caller passes these as part of `<user_context>`. Fail with a
request-for-info if any are missing:
- **NICHE** \u2014 registered niche slug
- **SLUG** \u2014 kebab-case article identifier
- **KEYWORD** \u2014 search intent target
- **ANGLE** \u2014 1-sentence unique take
- **RESEARCH_CONTEXT** \u2014 may be empty
Note the explicit "may be empty" on the last item. That is the optional-field marker. Everything above it is required.
Required vs optional: two different failure modes
The split matters because the failure mode for each is different.
A missing required field is a caller bug. The skill cannot proceed without the value, and any default would be a guess that pollutes downstream artifacts. The right behavior is to halt and surface the missing field to the caller.
A missing optional field is normal. The skill should apply a sensible default and proceed. The default must be null-safe: empty string, empty list, or a documented sentinel like "(empty)". Never None propagated into a string concatenation.
Here is the difference rendered as a guard at the top of a skill that produces, say, an article:
def validate_skill_inputs(ctx: dict) -> dict:
required = ["NICHE", "SLUG", "KEYWORD", "ANGLE"]
missing = [k for k in required if not ctx.get(k)]
if missing:
return {
"ok": False,
"message": f"Missing required inputs: {', '.join(missing)}. "
f"Please re-invoke with these fields populated.",
}
return {
"ok": True,
"ctx": {
**ctx,
"RESEARCH_CONTEXT": ctx.get("RESEARCH_CONTEXT") or "",
"TONE": ctx.get("TONE") or "neutral",
"MAX_WORDS": ctx.get("MAX_WORDS") or 1500,
},
}
The skill body itself is written in Markdown, not Python, but the pattern translates one-to-one: the first paragraph of procedure says "if any required input is missing, emit a fail-with-info response and stop." Every optional input has an inline default specified in plain language.
Fail with info, not with silence
The single most useful behavior is failing loudly with the names of the missing fields. Compare two failure modes for a skill called with no KEYWORD:
| Failure mode | Output |
|---|---|
| Silent default | Article about "general programming topics" \u2014 passes downstream gates because it looks plausible |
| Fail-with-info | "Missing required input: KEYWORD. Re-invoke with a search-intent target." |
The silent-default version is worse than a crash. It costs API tokens, it pollutes the backlog, and the caller has no signal to fix the upstream call. The fail-with-info version costs almost nothing and gives the caller exactly what they need.
Bake this into the skill body literally:
## Procedure
1. Parse NICHE + SLUG + KEYWORD + ANGLE from `<user_context>`.
2. If any required field is missing or empty, emit:
`MISSING_INPUTS: <comma-separated field names>. Re-invoke with values.`
and STOP. Do not produce article output.
3. Apply optional-field defaults (RESEARCH_CONTEXT \u2192 "", TONE \u2192 "neutral").
4. Continue with normal procedure.
The literal MISSING_INPUTS: prefix is a parseable signal. If your skill is invoked from a programmatic harness \u2014 a FastAPI orchestrator, a CI step, an agent dispatcher \u2014 that prefix lets the harness branch on validation failure without regex-matching prose.
Null-safe defaults: prefer empty over absent
For optional fields, choose defaults that are type-stable and operation-safe.
- Empty string
""overNonefor text fields. Lets youf"{ctx['notes']}"without aNoneTypeformatting crash. - Empty list
[]overNonefor repeated fields. Lets you iterate without a guard. - A documented sentinel like
"(empty)"only when the presence of the absence is meaningful in the output (e.g. a research context block that should still render a heading even when the body is empty).
The third option is rare. The first two cover 90% of cases. The pattern is: pick a default that lets every downstream operation run unchanged.
ctx = {
"RESEARCH_CONTEXT": user_input.get("RESEARCH_CONTEXT") or "",
"REFERENCES": user_input.get("REFERENCES") or [],
"TONE": user_input.get("TONE") or "neutral",
}
# Now every consumer can do this without guards:
prompt = f"Research:\
{ctx['RESEARCH_CONTEXT']}\
\
Refs:\
" + "\
".join(ctx["REFERENCES"])
Compare with the unsafe alternative \u2014 or "" versus an explicit if x is None check. The or form treats both None and "" as falsy, which is exactly what you want here: an empty string from the caller and an absent key both collapse to the same default. There is a corner case where 0 or False would also collapse, so use or for strings and lists, and is None for numerics or booleans where falsy values are legitimate.
Why this beats schema validation libraries for skills
You might reach for Pydantic or zod here. For an HTTP API, do that. For a Claude skill, the contract is enforced by the model executing the skill, and the validation has to be expressible in Markdown that the model will follow.
A Pydantic model gives you 50ms of strict validation but only at the boundary of a Python service. The skill body itself is interpreted by Claude, and Claude does not run your BaseModel.parse_obj() call. The Markdown contract \u2014 required list, fail-with-info procedure, null-safe defaults \u2014 is what actually runs.
This is the same reason Anthropic's MCP spec keeps tool schemas in JSON: the schema lives where the model can see it. For skills, the equivalent is plain prose at the top of SKILL.md.
Putting it together
A robust skill input contract has four parts:
- A
## Required Inputssection inSKILL.mdlisting every required field with a one-line description. - An explicit empty-allowed marker ("may be empty", "optional, defaults to X") on every optional field.
- A first-step procedure that fails with
MISSING_INPUTS: <fields>and stops if anything required is absent. - Inline defaults for every optional field, chosen for type stability so downstream prose-or-code operations don't need null guards.
Skills that do this ship more reliably than skills that rely on the model's "common sense" about what the caller meant. The 100 lines of upfront contract pay back the first time someone invokes the skill from an automated dispatcher and forgets a field \u2014 because instead of debugging a hallucinated article three hours later, you get a one-line MISSING_INPUTS: reply you can fix in 10 seconds.
References: