Skip to content

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.