This guide walks you from an empty file to a skill ready to freeze. By the end you will have a single `SKILL.md` document that declares the actions it calls, the environment it runs in, the inputs it resolves, the outputs it produces, and a deterministic step graph that wires them together with no language model in the loop.
If you have not read it yet, start with [Flight Plans](/concepts/flight-plans/) for the model. This guide is the *how*; that page is the *what* and *why*. The normative field reference is the [Flight Plan Manifest Spec](/development/flight-plan-manifest-spec/), and the companion guides downstream are [Freezing a Flight Plan](/guides/freezing-a-flight-plan/) and [Launching a Flight Plan](/guides/launching-a-flight-plan/).
This guide covers authoring by hand. The same manifest is also the form an [AI-assisted authoring flow](/development/ai-assisted-authoring-spec/) prompts an operator to fill in, but the artifact is identical either way.
## What you are building
A Flight Plan is a skill before it is sealed. The skill is one `SKILL.md` document in the [agentskills.io](https://agentskills.io) format, extended with a single Aileron-specific block. You author the skill. A freeze step turns it into a Flight Plan by resolving images to digests, locking, binding the execution environment, attaching the trust contract, and signing.
This shapes how you author. You write the contract; you do not write the seal. The `requires:` block, the inputs, the outputs, the step graph, and the per-action trust contract are yours to write. The lockfile, the digest pins, and the signature are produced by freeze, never hand-written.
The Aileron extension lives under one namespaced `aileron` key in the frontmatter. The extension is lossless if stripped. A tool that ignores the Aileron fields still reads a valid skill, and a host that lacks a named action reports a missing requirement rather than refusing to load the document.
## The skeleton
A complete, valid minimum:
```yaml
---
name: weekly-metrics-digest
description: Read a metrics window, summarize it, and file a tracking issue.
aileron:
schemaVersion: aileron.flightplan.v1
requires:
actions:
- ref: aileron:metrics.read_window
trustContract:
credential:
kind: oauth2
placement: header
oauth:
scopes: ["metrics.read"]
hosts: ["metrics.example.com:443"]
effect: read
idempotency:
safeToRetry: true
audit:
fields: ["network-target", "operation-effect", "response-summary"]
environment:
tools:
- [email protected]
inputs:
- name: window_days
type: number
description: How many days of metrics to read.
resolution:
rule: literal
default: 7
outputs:
- name: digest
mimeType: text/markdown
encoding: utf-8
publish:
target: file
path: digest.md
steps:
- id: read
kind: action-call
actionRef: aileron:metrics.read_window
args:
days: inputs.window_days
- id: summarize
kind: transform
bindings:
rows: steps.read.messages
outputs: ["text"]
materializesOutput: digest
---
# Weekly Metrics Digest
This skill reads a recent metrics window, writes a short digest, and files a
tracking issue that links to it.
```
Open the frontmatter block at the top; close it; write the body underneath. The `aileron` block is the only Aileron-specific part. The keys it sits beside (`name`, `description`, and the rest) are the agentskills.io skill keys.
## The `requires:` block
The `requires:` block lists the actions the plan calls. The sibling `environment:` block declares the container it runs in. Together they are the dependency declaration freeze reads when it pins and binds.
The `requires:` block is optional. A tool-only plan whose every effectful step is an in-container `tool` step dispatches no connector action, so it declares no actions and omits the block entirely. Do not carry a dummy action ref to satisfy the schema: an unused ref names nothing the plan calls and surfaces a spurious "not satisfiable here" warning at `aileron skill install`. Declare an action only when a step actually calls it.
### Actions and the per-action trust contract
Each `requires.actions[]` entry names one action by `ref` and carries a per-action `trustContract`. The `ref` is `aileron:<connector>.<action>`, and it attaches to the [action model](/concepts/actions/). An unsatisfied `requires:` entry is a missing-requirement signal the runtime surfaces, never a parse failure. The declared set stays load-bearing for a plan that does call actions: the runtime refuses an `action-call` step whose ref is not declared here, so every called action must appear.
The trust contract is the security surface for that action. You write it; freeze attaches it; the detached signature is the human attestation that it is correct. Every field below records something the runtime enforces or the user sees.
- `credential` declares the credential `kind` (`none`, `api-key`, `oauth2`, `aws-sigv4`) and its `placement` (`header`, `query`, `cookie`, `body`, `signing`, `session`). It never carries a credential value. An `oauth2` credential is always placed in a `header`; an `aws-sigv4` credential is always placed via `signing`.
- `oauth` is required when `credential.kind` is `oauth2`. It declares the scopes, endpoints, and refresh behavior, never a token.
- `hosts` is the closed list of upstream hosts the action reaches, as `host:port` pairs. The declared host set IS the security boundary. The runtime grants nothing undeclared. `paths` optionally narrows the request paths on those hosts.
- `effect` is one of `read`, `write`, `delete`, `spend`, `external-send`. It drives default approval routing. A `read` runs unattended; the other four raise an approval gate at launch.
- `idempotency.safeToRetry` declares whether re-running with the same inputs produces no additional effect. `idempotency.idempotencyKey` declares whether the operation accepts a client-supplied key. Read ops are typically safe to retry; writes that mutate the world are not.
- `redaction` optionally lists rules applied to response fields before they reach the agent or the author. A `drop` rule removes a field, a `mask` rule replaces its value, and a `hash` rule replaces it with a stable hash.
- `verification` optionally declares a read-only probe the runtime can call to confirm the credential still works.
- `audit.fields` declares which of the closed audit-record fields this operation emits. The closed set is `connector-hash`, `action-manifest-version`, `credential-binding`, `identity-label`, `approved-input`, `approval-decision`, `network-target`, `operation-effect`, `request-summary`, `response-summary`, and `result`.
Declare the minimum each action needs. The contract is verbose by design. A plan with many actions records many such blocks, and that is the cost of a per-action audit story the user can read.
### The execution environment
The `environment:` block declares the single container the plan runs in. It is a sibling of `requires:` under the `aileron` block, not nested inside it. The block is optional; omit it for a plan that needs no tooling. When present, it declares `tools`, an `image`, or both, and must declare at least one, so an empty `environment: {}` is rejected.
- `environment.tools` names curated-catalog tools the plan's steps invoke, each as `<name>@<version>` (for example `[email protected]` or `[email protected]`). The name set is closed to the curated catalog; an unknown name is rejected at validation, before freeze. The version grammar is loose (`2`, `2.x`, and `2.19.1` all validate). Freeze resolves each entry to its catalog devcontainer Feature and composes one image on the Aileron-provided runner base.
- `environment.image` names a custom base image, the escape hatch when the curated catalog does not carry the tooling you need. Freeze resolves a tag to an `image@sha256:` digest pin. Declaring both `tools` and `image` composes the declared tools onto your custom base.
Freeze resolves the declared environment to a single content-addressed digest, and launch boots that one image and runs the whole plan inside it. The execution container is agent-free. The image carries no coding agent. The Flight Plan runs composed steps, not an interactive agent session.
The `environment` block never names an identity. The credential binding lives on your machine at launch, not in the plan, so the identical sealed artifact runs under different vault-bound identities and the audit trail records who ran it.
## Inputs
Every input the plan depends on is declared in `inputs[]`, each with a resolution rule. A value that varies by use case, such as a time window, is a declared input rather than a constant baked into the composition, so one composition serves many operators.
- `name` is the input name, unique within the manifest.
- `type` is one of `string`, `number`, `boolean`, `timestamp`, `object`, `array`.
- `description` is optional human-readable semantics.
- `example` is an optional first-class example value.
- `prompt` is an optional guided-walk visibility marker.
- `resolution` is the resolution rule, discriminated by its `rule` field.
- `constraint` is an optional bound on the resolved value.
There are three resolution rules.
- `literal` takes a value passed at launch. An optional `default` applies when no value is passed.
- `dynamic` resolves a launch-relative value once at launch. Its `value` is `now` (the launch timestamp) or `today` (the launch date).
- `source` reads from a live source. Its `source.actionRef` names the action whose result resolves the input, with an optional `source.select`.
Inputs resolve once, at the launch boundary, into a concrete resolved-input set. Two steps that read the same dynamic input see one value. A `source` read happens in the resolution phase, before the step graph walks, so it is not a graph edge.
An input can carry an optional `example`, a first-class example value the guided launch walk displays on the prompt line. This keeps example values out of the `description`, where they used to be jammed. Any value type is allowed, so an example may be a string, a number, or a structured object. A large example is shown as a compact type and size summary so it never floods the terminal. The example is display-only and never affects resolution or whether the input is required.
An input can also carry `prompt: false` to mark it as advanced or non-interactive. The guided launch walk skips such an input and its declared default applies silently, so an operator is not asked to hand-type a value nobody edits, such as a large inlined template. The input stays overridable via `--input`, and an explicit override still wins and is shown as already set. An absent `prompt` key, or `prompt: true`, means the walk prompts for the input as usual.
An input can carry an optional `constraint` that bounds its resolved value. A constraint holds exactly one of two forms. An `enum` is a non-empty array of allowed string values, and the resolved value's string form must equal one entry. A `pattern` is a non-empty author-anchored Go RE2 regexp, and the resolved value's string form must match it. Declaring both forms at once, an empty `enum`, an empty `pattern`, or a pattern that does not compile is a bad shape rejected at freeze and at decode. A resolved value that falls outside its constraint is rejected at launch and the launch fails closed. The constraint applies to any resolution rule, and the check is over the value's string form, so a number or timestamp input stays bounded. A constraint is meaningful only on a scalar-typed input, meaning `string`, `number`, `boolean`, or `timestamp`. An `enum` or `pattern` on an `object` or `array` input is effectively unusable, because it would only ever match Go's rendering of a map or slice.
```yaml
inputs:
- name: environment
type: string
resolution:
rule: literal
constraint:
enum:
- prod
- staging
- name: aws_region
type: string
resolution:
rule: literal
constraint:
pattern: "^[a-z]{2}-[a-z]+-[0-9]$"
```
## Outputs
The `outputs[]` block is the declared contract for what the plan produces. It is kept distinct from the transport so the transport can change later without changing the contract.
- `name` is the artifact name, unique within the manifest. The audit references artifacts by this name.
- `mimeType` is the artifact media type.
- `encoding` is `utf-8` or `base64`. v1 implements `utf-8` only. `base64` is reserved.
- `publish.target` is `file` or `none`. When `target` is `file`, `publish.path` names the output file.
A step result reaches a declared output through the step's `materializesOutput` field, which names a declared `outputs[].name`.
## The deterministic step graph
The `steps[]` block is the composition. It wires declared actions and deterministic transforms into a directed acyclic graph. The block is optional. A skill with no `steps` block is instruction-only and still a valid manifest.
Every step carries an `id` unique within the graph and a `kind` from a closed enum. The `kind` is what makes the no-LLM guarantee structurally checkable.
| `kind` | Reaches an LLM | Semantics |
|---|---|---|
| `action-call` | No | Invokes a declared action. Its `actionRef` names the action and its `args` bind the action's arguments. |
| `transform` | No | Runs deterministic no-LLM logic over data already in the graph. It has no host, network, or credential surface. |
| `tool` | No | Runs a declared environment tool as a deterministic subprocess inside the booted plan container. Its `command` is an argv array run with no shell interpretation. Its optional `mount` and `collect` are the file-I/O boundary, and its optional `trustContract` declares the step's network reach. |
| `llm-seam` | Yes | A marked non-deterministic seam. A plan may declare one or more. The only kind that reaches an LLM. |
A `tool` step runs one of the tools your `environment` declared. Give it a `command` argv array (the program then its arguments, never a shell string), and optionally a `mount` to place input files into the container and a `collect` to read a produced path back as the step output. If the tool reaches the network, declare its reach with a per-step `trustContract` whose `hosts` list the upstream it may call. Freeze seals that reach into the lock, and launch enforces it: a scoped call to an undeclared host is refused at the daemon proxy before any TLS handshake, and no credential bytes ever enter the container.
A `command` element may embed a `{{ inputs.<name> }}` token to parameterize the argv from a declared input. The runtime substitutes the token with the input's resolved value at launch. So `--region={{ inputs.region }}` execs as `--region=us-east-1`. The substitution stays inside the element and never introduces a shell or splits the argv, so the no-shell guarantee holds. The referenced input must declare a `constraint` (an `enum` or a `pattern`). Freeze rejects a token that references an unconstrained input, because an unbounded value in a signed command position is an injection surface. Bound the value with a constraint and the sealed command stays within the shape you signed. A token referencing an undeclared input, or a malformed token, is rejected at freeze and again at launch load.
A `trustContract.hosts` entry may embed a `{{ inputs.<name> }}` token the same way, so a region-parameterized endpoint like `athena.{{ inputs.aws_region }}.amazonaws.com` is one host entry rather than one entry per region. The referenced input must declare a `constraint`, and freeze rejects a host token that references an unconstrained input, because an unbounded value in a signed host position could steer the step's egress to an attacker-chosen host. The sealed host template is instantiated from the resolved input at launch, and the daemon proxy matches the concrete host by exact string, so a sibling region the constraint does not admit is denied. A host token referencing an undeclared input, or a malformed token, is rejected at freeze and again at launch load.
Constraining an input and templating the reach is what makes a plan retargetable. A single frozen and published plan is pointed at any admitted endpoint without a re-freeze, because the signature covers the reach template and the constraint that bounds it rather than one fixed endpoint.
Wire steps with bindings, never values. A binding is a reference. An `action-call` step binds its `args`; a `transform`, a `tool`, and an `llm-seam` step bind their `bindings`. A binding takes one of two forms.
- `inputs.<name>` references a declared input resolved once at the launch boundary.
- `steps.<stepId>.<outputName>` references a named output of a prior step.
The references make the wiring a graph. The runtime executes it in deterministic topological order, breaking ties by declaration order.
Three rules a JSON Schema cannot express, that the freeze lint checks and the runtime enforces:
- The graph is acyclic.
- An `action-call` step's `actionRef` must match a declared `requires.actions[].ref`.
- An `llm-seam` is the only kind that may reach an LLM.
No step field may hold a secret. Every step kind is a closed object, and the `args` and `bindings` grammar is closed to `inputs.<name>` and `steps.<id>.<output>`, so a literal secret cannot be embedded in the wiring.
### Wiring the no-LLM seams
Behavioral determinism is the whole point. No LLM runs at Flight Plan runtime by default. If your plan needs LLM reasoning, mark each such step `kind: llm-seam`. A plan may declare more than one seam. Everything else is `action-call` and `transform`, both of which hold no reference to any language-model type.
In v1 the seam is unwired by default. An `llm-seam` step with no configured provider is a hard error at launch, so a default launch reaches no model at all. A skill that reaches an LLM outside a marked seam fails the freeze lint and never becomes a Flight Plan. When a plan declares more than one seam, each seam suspends the run for its LLM output, and the run resumes once that output is supplied.
## The Markdown body
Everything after the closing frontmatter is the body. It is the agentskills.io skill body. Write what the skill does, the inputs it expects, and the output it produces. The body is the prose a reader and a skill-aware host consume, and it stays a valid skill even with the Aileron block stripped.
## Handoff to freeze and sign
When the skill validates and behaves the way you want, hand it to freeze. Freeze is the boundary between a skill and a Flight Plan.
```sh
aileron skill freeze weekly-metrics-digest \
--signing-key ~/.aileron/keys/author.pem \
--version 1.0.0
```
Freeze parses and lints the manifest, rejecting any step that could reach an LLM outside the marked `llm-seam`. It resolves the execution environment to image digests. It builds the lockfile. It content-addresses the unit and signs it with a detached ed25519 signature. It writes the frozen version into the store as an immutable directory.
The signing key is a PEM-encoded ed25519 private key, read for signing only and never copied into the artifact. The detached signature is your attestation that the per-action trust contract is correct. See [Freezing a Flight Plan](/guides/freezing-a-flight-plan/) for the full command and the reproducibility and signature-verification guarantees.
After freeze, [launch](/guides/launching-a-flight-plan/) runs the frozen Flight Plan deterministically.
## Common authoring mistakes
- **A literal secret in the wiring.** No step field holds a value. `args` and `bindings` are references (`inputs.<name>` or `steps.<id>.<output>`), and the credential lives in the trust contract as a `kind`/`placement` declaration, never a token.
- **An unmarked LLM call.** Any reach to a model outside a `kind: llm-seam` step fails the freeze lint. Mark each seam, or restructure the plan to keep the logic deterministic.
- **An `actionRef` with no matching `requires.actions[].ref`.** A step can only call an action the `requires:` block declares. The freeze lint catches the mismatch.
- **Hand-writing the lock.** The `lock` section is produced by freeze. It is absent before freeze, and you never author it.
- **A `base64` output in v1.** The `encoding` field reserves `base64`, but the v1 runtime materializes `utf-8` text only. A binary artifact waits on the deferred escape hatch: a `tool` step's mount and collect file-I/O boundary.
## Where to go next
- [Flight Plans](/concepts/flight-plans/). The model behind everything in this guide.
- [Flight Plan Manifest Spec](/development/flight-plan-manifest-spec/). The normative field reference, with the full schema and a worked example.
- [Freezing a Flight Plan](/guides/freezing-a-flight-plan/). Seal the skill into a signed Flight Plan version.
- [Launching a Flight Plan](/guides/launching-a-flight-plan/). Run the frozen Flight Plan deterministically.
- [ADR-0027: Flight Plan = Sealed Installable Skill](/adr/0027-flight-plan-sealed-installable-skill/). The design constraints behind this layer.