Typed Inputs and Structured Outputs¶
Why does a skill need a typed contract?¶
If several workflows, agents, or application services use the same skill, it is not enough to “roughly know” what data it expects and what it returns.
A reusable capability becomes a stable component when the caller and skill share an explicit contract:
Caller
↓
Typed Skill Input
↓
Skill Runtime
↓
Structured Skill Result
↓
Application mapping / validation
This is the same principle used for APIs and service interfaces.
The goal is not to make the LLM “object oriented”. The goal is to put a deterministic interface boundary around the probabilistic model.
Free-text input vs typed input¶
A skill can naturally receive free text:
Review this PR and focus on architecture.
But in a reusable runtime, it is useful to structure the task input:
{
"repository": "org/payment-service",
"pull_request_number": 184,
"focus": ["architecture", "correctness"],
"include_tests": true
}
Benefits of typed input:
- it can be validated before the model call,
- required/optional fields are explicit,
- it is easier to version,
- it is easier to log and evaluate,
- it composes more easily with other skills/workflows,
- fewer meanings remain implicit.
The skill can still contain natural-language fields:
{
"task": "Review this change for backwards compatibility",
"repository": "org/api-service",
"pull_request_number": 42
}
Structure and natural language are not opposites.
Validate input before the model¶
If an input is deterministically invalid, do not ask the LLM to repair it.
For example:
{
"environment": "moon",
"service": "payment-service"
}
If the environment must be:
dev | staging | production
then:
input
↓
schema validation
↓
INVALID_INPUT
is better than:
input
↓
LLM guesses that "moon" probably means staging
General rule:
If the runtime can validate something with certainty, do not delegate it to semantic model reasoning.
Structured output as a skill API¶
If a human reads the result, free text can be perfectly suitable.
If another component consumes the result, structured output is preferable.
For example, a ticket-triage skill:
{
"category": "BILLING",
"priority": "HIGH",
"confidence": 0.91,
"reason": "The customer reports a duplicate charge."
}
The workflow no longer parses prose such as:
if "HIGH" in model_output:
but uses typed fields:
result.priority == HIGH
Schema correctness vs semantic correctness¶
This distinction is critical.
Structured output can guarantee something like:
priority ∈ {LOW, MEDIUM, HIGH}
but it does not guarantee that the model chose HIGH correctly.
schema correctness
≠
semantic correctness
The schema can guarantee:
{
"priority": "HIGH"
}
Semantic evaluation asks:
Was this case actually HIGH?
Typed contracts are therefore only one reliability layer.
LLM-facing DTO vs domain object¶
A very useful separation is:
LLM result
↓
SkillResult DTO
↓
validation / mapping
↓
Application / Domain model
For example, a refund skill may return:
{
"recommendation": "REQUIRES_APPROVAL",
"amount": 140,
"currency": "EUR",
"reason": "Amount exceeds direct-approval threshold."
}
The application's domain model may be much richer:
RefundRequest aggregate
├── Money
├── CustomerAccount
├── ApprovalPolicy
├── PaymentTransaction
└── AuditMetadata
The LLM does not need to construct the domain aggregate directly.
Prefer:
simple LLM-facing DTO
↓
deterministic mapper
↓
rich domain model
This reduces coupling and prevents provider-specific schemas from leaking into the domain layer.
The input DTO does not have to be a domain object either¶
The domain may contain a complex object such as:
PullRequestReviewContext
while the skill only needs:
{
"repository": "org/service",
"pr_number": 184,
"focus": ["correctness", "tests"]
}
The skill contract should be a minimal capability contract, not a mirror of the application's entire internal state.
Optional fields: missing, null, and unknown¶
AI output often blurs distinct states:
not provided
not applicable
unknown
failed to retrieve
These should not be treated as equivalent.
Weak schema:
{
"owner": null
}
We do not know what null means.
A better design may use an explicit state:
{
"owner_status": "UNKNOWN",
"owner": null
}
or a result envelope:
{
"status": "PARTIAL_RESULT",
"data": {
"service": "payment-service"
},
"missing": ["service_owner"]
}
This is especially important in agentic workflows, where the next step may depend on these states.
Result envelope pattern¶
A generic skill result may look like:
{
"status": "SUCCESS",
"data": {},
"warnings": [],
"errors": [],
"confidence": 0.93
}
Possible statuses:
SUCCESS
PARTIAL_RESULT
INVALID_INPUT
INSUFFICIENT_CONTEXT
NOT_AUTHORIZED
TOOL_UNAVAILABLE
FAILED
This is often better than representing every failure as an exception or free text.
Do not, however, hide every domain-specific result inside one overly generic envelope. Domain-specific data should remain explicit.
Discriminated result types¶
In many cases an even clearer design is:
SkillResult
├── SuccessResult
├── InsufficientContextResult
├── NotAuthorizedResult
└── ToolFailureResult
Conceptual JSON:
{
"type": "INSUFFICIENT_CONTEXT",
"missing": ["current deployment state"]
}
or:
{
"type": "SUCCESS",
"health": "DEGRADED",
"evidence": ["5xx rate increased to 18%"]
}
This composes very well in workflows.
Use enums where the domain is genuinely closed¶
For example:
severity = SEV1 | SEV2 | SEV3 | SEV4
is a good enum.
But:
root_cause = DATABASE | NETWORK | CODE | OTHER
should be closed only if that truly models the domain.
An overly narrow enum can force incorrect classification:
actual cause = expired certificate
model forced to choose NETWORK
A better design may use:
root_cause_category
root_cause_description
or explicit values such as:
UNKNOWN
OTHER
Be careful with confidence fields¶
A model-generated value such as:
{
"confidence": 0.92
}
is not automatically a calibrated probability.
Do not treat it as mathematically meaning:
92% chance that the answer is correct
Confidence can be useful as a routing signal only when evaluation tells us how it behaves.
For example:
model confidence < 0.6 → human review
is a valid policy only if measurements support it.
Typed contracts for skill composition¶
Suppose:
Incident Triage Skill
↓
Diagnostic Recommendation Skill
If the first skill returns free text:
"Looks like a database issue, maybe severe."
the second skill must reinterpret it.
Better:
{
"severity": "SEV2",
"suspected_component": "database",
"evidence": ["connection timeout spike"],
"confidence": 0.81
}
The next skill can consume this as typed input.
Skill A typed output
↓
validation
↓
Skill B typed input
This reduces semantic drift across the chain.
Contract compatibility¶
If v1 output is:
{
"severity": "SEV2",
"summary": "..."
}
and v2 becomes:
{
"severity": "SEV2",
"summary": "...",
"evidence": []
}
this may be backwards compatible if evidence is optional.
But changes such as:
severity enum changes
or:
summary string → structured object
may break downstream callers.
Treat skill contracts like API schemas with respect to compatibility.
Example: Deployment Decision Skill¶
Input:
{
"service": "payment-service",
"environment": "production",
"candidate_version": "7.5.0",
"current_health": "HEALTHY",
"test_status": "PASS"
}
Output:
{
"recommendation": "PROCEED",
"risk": "MEDIUM",
"reasons": [
"All required tests passed",
"Production is currently healthy"
],
"required_approvals": ["production-owner"]
}
The skill produces a recommendation.
Actual deployment policy can remain deterministic application logic:
recommendation == PROCEED
+
approvals satisfied
+
change window open
+
authorization
↓
allow deployment
Anti-pattern: JSON as a domain guarantee¶
Receiving:
{
"authorized": true
}
does not grant authorization.
The model is not the final authority for access decisions.
Likewise:
{
"price": 19.99
}
does not make that the real current price unless it came from a reliable source.
Anti-pattern: regex and parser-recovery chains¶
Weak flow:
"Return JSON please"
↓
free-form string
↓
strip markdown fences
↓
regex repair
↓
JSON parse
↓
random defaults
If the platform supports schema-constrained structured output, use it.
explicit schema
↓
structured output
↓
typed DTO
↓
semantic/business validation
Anti-pattern: domain aggregate as direct model output¶
If a domain object contains:
- invariants,
- persistence metadata,
- service dependencies,
- authorization state,
- internal lifecycle fields,
then do not force the LLM to represent that full aggregate.
The LLM-facing schema should be simple, stable, and task-specific.
Takeaways¶
- Typed input/output contracts provide a deterministic boundary around a skill.
- Schemas provide structural correctness, not semantic truth.
- Validate input before the model call wherever possible.
- Use LLM-facing DTOs and map them deterministically into domain objects.
- Distinguish
missing,unknown,not applicable, and failure states. - Result envelopes or discriminated result types compose well in workflows.
- Use narrow enums only for genuinely closed domains.
- Do not treat model-generated confidence as automatically calibrated probability.
- Typed skill composition reduces semantic drift in chains.
- Treat skill contracts like APIs with respect to compatibility and versioning.