> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# Skill schemas

A launchable skill can declare typed **input** and **output** contracts in its
`SKILL.md` frontmatter. When you run the skill with `tessl launch skill --cloud`, Tessl validates the launcher-supplied inputs against the declared
input schema before the skill runs, and validates the recorded result against
the declared output schema when it finishes — so a launch either conforms to the
contract or fails fast with a field-level diagnostic.

This page covers authoring the `schemas` block, the `$ref` forms it accepts, how
those refs are resolved, and the structured `result` a launch returns.

## Authoring the `schemas` block

Declare schemas under a single top-level `schemas` key in the skill's `SKILL.md`
frontmatter. It has two optional slots, `inputs` and `output`:

```yaml
---
name: greet
description: Greet someone by name.
schemas:
  inputs:
    $ref: ./schemas/inputs.schema.json
  output:
    $ref: ./schemas/output.schema.json
---
```

Each slot is either an **inline JSON Schema** or a single **`$ref`** reference.
Both slots are optional and independent — declare one, both, or neither:

- **`schemas.inputs`** — validated against the launcher-supplied inputs (the
  `INPUTS` object, keyed by the skill's own placeholder names) before the skill
  runs. A missing required input fails the launch during preparation rather than
  being discovered mid-run.
- **`schemas.output`** — describes the structured payload the skill produces. It
  is composed into the returned result (see [The returned result](#the-returned-result)).

A slot may be written inline instead of via a `$ref`:

```yaml
schemas:
  output:
    type: object
    properties:
      message: { type: string }
    required: [message]
    additionalProperties: false
```

For a complete, copyable example — a `greet` skill with both slots and a bundled
`./schemas/` directory — see the
[quickstart-typed-skill](../tiles/quickstart-typed-skill/index.md) tile.

## `$ref` forms

A slot's `$ref` must be a non-empty string and the **sole** key of the slot
(you cannot mix `$ref` with other keys). Three forms are accepted:

| Form       | Example                                     | Resolved from                                                                    |
| ---------- | ------------------------------------------- | -------------------------------------------------------------------------------- |
| **inline** | `{ type: object, ... }` (no `$ref`)         | Used as-is; nothing is fetched.                                                  |
| **local**  | `{ $ref: ./schemas/output.schema.json }`    | A file read relative to the `SKILL.md` directory. Must start with `./` or `../`. |
| **remote** | `{ $ref: https://example.com/schema.json }` | Fetched over HTTPS at launch preparation time.                                   |

Anything else (a bare name, an `http://` URL, a fragment-only `#/...` pointer)
is rejected.

### Local `$ref`s are contained to the skill bundle

A local `$ref` is resolved relative to the directory holding the `SKILL.md`, but
it is **contained** to the skill's bundle: a ref may point at a sibling or a
shared file elsewhere in the same bundle (`../shared/output.schema.json`), but
one that escapes the bundle directory — lexically or through a symlink — is
rejected. This lets skills that genuinely share a contract stay DRY by pointing
a local `$ref` at one shared file, without a ref reaching outside the bundle.

### Remote `$ref`s are fetched safely

A remote `$ref` is fetched only at launch **preparation** time (never during
plain validation), over a guarded HTTPS client:

- **HTTPS only**, with no embedded credentials.
- **No private or internal targets** — `localhost`, private/reserved IP ranges,
  and link-local / unique-local IPv6 are refused. The host is re-resolved and
  pinned to the validated address at connection time, so a DNS-rebinding trick
  cannot swap in an internal address after the check.
- **Bounded** — a 10-second timeout and a 1 MiB body cap; HTTP redirects are
  refused outright.

The fetched body must be valid JSON describing a JSON Schema object.

## Resolution precedence

When a launch prepares a skill, each declared slot is resolved into a fully
inline JSON Schema:

1. **Read** the `schemas` block from the skill's `SKILL.md` frontmatter. A skill
   with no `schemas` block declares no contract — its inputs are not validated
   and its output is left unconstrained.
2. **Resolve** each slot by its form: an inline schema is used as-is; a local
   `$ref` is read from the bundle; a remote `$ref` is fetched.
3. **Inline** the resolved schema. Resolution is **single-level** — a `$ref`
   inside a resolved schema document is not followed. Author self-contained
   schema files.

If a slot cannot be resolved (a malformed block, an unreadable local file, an
unfetchable or unsafe remote URL), the launch fails during preparation with a
diagnostic naming the failing slot, rather than proceeding with a partial
contract.

## The returned result

Every cloud launch returns an **envelope**, regardless of whether the skill
declared any schemas. It is one of four shapes, chosen by its `status`, and each
shape carries only the fields listed for it:

```json
{ "status": "succeeded",   "summary": "One sentence on what the skill did.", "output": { } }
{ "status": "no_action",   "summary": "The skill ran but took no action." }
{ "status": "needs_input", "summary": "…", "questions": ["<the exact question>"] }
{ "status": "failed",      "summary": "…", "reason": "<why it could not complete>" }
```

- **`status`** says what the skill did. `succeeded`: it did its work.
  `no_action`: it ran but made no change. `needs_input`: it asked a blocking
  question and stopped. `failed`: it tried and could not complete. This is the
  skill's own account, distinct from the launch run's own status.
- **`summary`** is a short human-readable sentence, required on every shape.
- **`questions`** carries the exact questions the skill asked, on
  `needs_input`.
- **`reason`** says why the skill could not complete, on `failed`.
- **`output`** is the skill-governed structured payload, on `succeeded` only.

`output` appears when the skill declares a `schemas.output`: that resolved
schema becomes the `output` property and `output` becomes **required**, so the
skill must produce a payload conforming to its own declared schema and the
launch validates it. When the skill declares no output schema, `output` is not
permitted at all. `output` belongs entirely to the skill, so there is nothing
for it to hold when the skill has not said what it holds. A skill that needs to
report structured detail on a non-`succeeded` outcome puts it in `summary` or
`reason`.

**`needs_input` is only available to a launch that can route the answer.** A
task turn can put a blocking question to a human and resume, so its contract
carries that shape; an interactive or scheduled launch cannot, and its contract
omits it. On those, record the blocking question as `failed` with the question
in `reason`.

### Consuming the result from the CLI

`tessl launch skill --cloud` renders the returned result when a launch
completes, and `--json` emits the whole envelope for a script to consume:

```sh
$ tessl launch skill engteam/greet --cloud --repo engteam/my-repo --json
{
  "launchRunId": "…",
  "status": "completed",
  "result": {
    "status": "succeeded",
    "summary": "Greeted Ada.",
    "output": { "message": "Hello, Ada!", "recipient": "Ada" }
  }
}
```

See [`tessl launch skill`](/reference/cli-commands.md#tessl-launch-skill) for the full flag
reference.
