AileronControlPlane

Authoring a Flight Plan

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 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, and the companion guides downstream are Freezing a Flight Plan and Launching a Flight Plan.

This guide covers authoring by hand. The same manifest is also the form an AI-assisted authoring flow 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 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:

---
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. 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.

  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.

kindReaches an LLMSemantics
action-callNoInvokes a declared action. Its actionRef names the action and its args bind the action’s arguments.
transformNoRuns deterministic no-LLM logic over data already in the graph. It has no host, network, or credential surface.
toolNoRuns 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-seamYesA 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.

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 for the full command and the reproducibility and signature-verification guarantees.

After freeze, launch 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