Aileron Docs

ADR-0003: Action Model

StatusAccepted
Date2026-04-29
Tracking#343

Context

Connectors (ADR-0002) bring the ability to talk to a specific external service: an OAuth flow, an HTTP request shape, the right error handling. Actions are the layer above connectors — they describe a purpose the agent can fulfill. “Post a ship-update to Slack.” “File a bug in Linear.” “Send an email reply.” Each action composes one or more connector operations into a unit the agent can invoke and the user can reason about.

The architectural questions for actions are different from those for connectors:

  • Where does the action manifest live? In a registry the runtime fetches from, or in the developer’s own repo?
  • Who owns the action after it’s installed — the publisher who wrote it, or the developer who installed it?
  • How do actions compose — through an internal dependency graph, or by emergent agent orchestration?
  • What does the action declare beyond “use this connector” — does it pin a capability subset, or inherit the connector’s full grant?

These are not decisions about runtime mechanics; they are decisions about the contract between Aileron, the action author, the developer, and the agent. They shape what code lives where, what gets reviewed in PRs, and what surprises are possible at runtime.

This ADR ratifies that contract.

Decision

An action is a single declarative file owned by the user

An action is a single file: TOML frontmatter declaring the contract, Markdown body documenting the action and serving as the LLM-facing function description (per ADR-0001). It lives in the user’s home directory at ~/.aileron/actions/. Actions are personal capability extensions for the user, not project-coordination state.

~/.aileron/
├── actions/
│   ├── ship-update.md
│   ├── reply-to-pm.md
│   └── file-bug.md
├── store/
└── vault/

This is the same shape as user-level CLAUDE.md, shell aliases, or editor extensions: I install the actions I want for my own use; my teammates install whichever actions they want. There is no team-coordination layer in v1.

Project-level actions are post-MVP. A future ADR may introduce a <project>/actions/ surface for teams that want to commit a shared action set to their repo. v1 does not implement that surface — actions are exclusively user-level. Defer until a concrete team asks for it.

The action file is the contract Aileron executes. There is no separate manifest, no generated artifact, no lock file. Reading the file tells you exactly what will run, against which connector versions, with which capabilities.

Actions are copied on install, not registered

aileron action add <FQN> fetches a template from any supported source, copies it into the user’s ~/.aileron/actions/, and exits. From that moment the user owns the file. They can edit it, restructure it, retitle it, change the connector versions it references, modify the prompts and triggers — and none of it requires anyone’s permission or a republish.

$ aileron action add hub://aileron/[email protected]
→ Fetching action from hub://aileron/[email protected]...
✓ Action file written to ~/.aileron/actions/ship-update.md
  Source: hub://aileron/[email protected]

$ aileron action add github://acme/templates/actions/[email protected]
→ Fetching action from github://acme/templates/actions/[email protected]...
✓ Action file written to ~/.aileron/actions/file-bug.md
  Source: github://acme/templates/actions/[email protected]

This is the ShadCN distribution model applied to actions: a source is a curated catalog of starting-point templates, not a runtime registry. Aileron does not phone the source at runtime to “load an action”; it reads the local file. A user’s installed action set is reproducible from the action files in ~/.aileron/actions/ — they can be backed up, dotfiles-managed, or version-controlled in a personal repo, but they are not part of any project the user works on.

Action templates are hostable from any scheme. Actions and connectors are symmetric in their distribution model: both are FQN-identified, both can live on github://, gitlab://, hub://, or any future scheme the resolver knows. The Hub is a curated catalog with discoverability and review, not a privileged source. Publishers who prefer to host their own action templates on GitHub or GitLab can do so; install commands and provenance fields use the FQN of the chosen source.

Monorepo support carries over from ADR-0002. A single repo can host multiple action templates at distinct subpaths: github://aileron/integrations/actions/ship-update, github://aileron/integrations/actions/file-bug. The Hub similarly allows organizational subpaths within an owner’s namespace.

Versioning inherits the rules from ADR-0002. Action templates are pinned to strict SemVer; the source field includes @<version>; the local action file records the exact source FQN+version it was copied from. The local copy is canonical from install onward; the source version is provenance, not a runtime dependency.

The action file records its provenance with a fully-qualified URI in the source field. Update tooling uses this to offer newer template versions when they become available, but the local copy remains canonical until the user accepts a diff.

Note the asymmetry between the action’s own name and its references to other artifacts:

  • The action’s name field is a bare local handle (e.g., name = "ship-update"). The user owns the file post-install and chooses the name; FQN doesn’t apply to a file the user owns.
  • The source field is a fully-qualified URI indicating where the template came from.
  • The [[requires.connectors]] blocks reference connectors by their fully-qualified URI (per ADR-0002), since connectors are shared, sandboxed binaries that need unambiguous identification across the ecosystem.

Actions are atomic — no inter-action dependencies

An action does one thing. It does not declare dependencies on other actions. There is no action-to-action import mechanism, no “extends,” no shared lifecycle, no compositional language for chaining actions.

When a user wants a compound operation — “ship-update + create-followup-ticket + block-calendar” — they have two options:

  1. Let the agent orchestrate. The agent already calls actions; chaining ship-update then create-followup-ticket then block-calendar in conversation is its native mode. This is the default and almost always the right choice.
  2. Write a new action that performs all three. The new action declares dependencies on the relevant connectors (e.g., github://aileron/slack, github://aileron/linear, github://aileron/gcal) rather than on three other actions, and orchestrates the connector calls itself.

Both options keep the dependency graph at depth 1: actions depend on connectors. Connectors depend on nothing inside Aileron.

This is a deliberate simplification, not a deferred decision. Action-to-action dependencies would introduce a transitive dependency graph (versioning, conflict resolution, lifecycle ordering, partial-failure semantics, debugging across action boundaries) for marginal value. The two existing composition paths cover the use cases without the graph.

Action files declare connector dependencies and capability subsets

Each action file lists the connectors it uses. For each connector, it pins the fully-qualified URI name (per ADR-0002), the exact version, the content hash, and the subset of the connector’s declared capabilities that the action actually exercises:

[[requires.connectors]]
name = "github://aileron/slack"
version = "1.2.0"
hash = "sha256:abc123..."
capabilities = ["chat:write", "channels:read"]

[[requires.connectors]]
name = "github://aileron/git"
version = "2.1.0"
hash = "sha256:def456..."
capabilities = ["read"]

The capabilities field is the action’s declared subset of what the connector is capable of doing. The Slack connector might also expose chat:read, users:read, and files:upload; if ship-update only needs chat:write and channels:read, that’s all the action declares.

This is enforced at runtime. If ship-update at execution time tries to invoke chat:read, the runtime denies the call at the action boundary — even if the connector itself is capable of the operation. Two boundaries, two checks: the connector cannot exceed its manifest, the action cannot exceed its declared subset.

Defense in depth, with two practical benefits:

  • Audit is precise without reading the execution body. Anyone reading the action file can scan the [[requires.connectors]] blocks at the top and know exactly what the action will touch.
  • Capability creep is visible. If the user or an upstream template update adds a new capability to an action, the change shows up as a TOML diff in the same file. There is no hidden expansion of what the action is allowed to do.

Action inputs are declared in [[inputs]] blocks

An action accepts call-time arguments — the values the agent (or the LLM) supplies when invoking the action. Each argument is declared in an [[inputs]] block:

[[inputs]]
name = "channel"
type = "string"
description = "Slack channel to post to (e.g. '#engineering')."

[[inputs]]
name = "max_lines"
type = "integer"
required = false
description = "Maximum lines of context to include in the post."

Fields:

  • name — the argument’s identifier. Must match ^[a-z][a-z0-9_]*$. Referenced in [[execute]] step inputs as ${args.<name>}.
  • type — one of string, integer, number, boolean. Maps directly to the corresponding JSON Schema primitive.
  • required — optional, defaults to true. Set to false to make the argument optional.
  • description — required. Becomes the field-level description the LLM sees when the action is exposed as a tool.

The [[inputs]] block is the contract between the action and the LLM (per ADR-0008). When Aileron augments the agent’s tool catalog with an action, it derives a JSON Schema parameters object directly from the inputs:

{
  "type": "object",
  "properties": {
    "channel":   { "type": "string",  "description": "Slack channel to post to..." },
    "max_lines": { "type": "integer", "description": "Maximum lines of context..." }
  },
  "required": ["channel"]
}

The shape is what the LLM uses to choose arguments at tool-call time. Authors who want the LLM to pick the right values write tight description prose and pick the narrowest type that fits; the LLM does the rest.

Validation runs at parse time:

  • Every [[execute]] step’s ${args.<name>} reference must resolve to a declared input.
  • An action with no [[inputs]] and no ${args.*} references is valid; its parameters schema is the empty object { "type": "object" }.
  • Duplicate input names are rejected.

Credential binding lives outside the action file

Action files declare what kinds of credentials their connectors need (via the [capabilities.credential] block on each connector manifest in [[requires.connectors]]), but they do not name vault paths or pick concrete credentials. Per ADR-0006, bindings are per-user state managed by the aileron binding CLI and the /v1/bindings/* endpoints — not embedded in action files. The runtime resolves the right binding at credential-mediation time by matching (connector_fqn, capability kind) against the user’s binding store.

This separation means an action authored by someone else can run on the user’s machine without that author knowing or caring which vault path the user picked. It also means rotating a credential is one CLI invocation (aileron binding rebind ...) rather than an action-file edit.

The Markdown body is the documentation and the LLM-facing description

Per ADR-0001, the body of the action file is Markdown. It serves three readers simultaneously:

  • The user reading the action to understand what it does and how to maintain it.
  • The Hub rendering the action as a documentation page when browsing.
  • The LLM, which receives the body (or its first paragraph, or a designated section) as the description of the function when Aileron exposes the action as a tool to the agent.

Authors write one piece of prose. There is no separate “LLM hint” field to keep in sync with the human documentation.

Updates are visible, never silent

When the source publishes a new version of ship-update, the user’s local file does not change. aileron action update ship-update fetches the new template (from the FQN recorded in source) and produces a diff against the local file. The user accepts, rejects, or merges manually.

Aileron will not silently update an installed action. There is no “auto-follow latest” mode. If an upstream connector publishes a security fix, the user must explicitly bump the connector version in the action files that reference it. The runtime will not switch transitively.

Alternatives Considered

Actions as a hosted catalog the runtime fetches at execution time (rejected)

The source (Hub, GitHub, or any other) serves actions as a live registry. The runtime fetches the action FQN at execution time and runs the freshly fetched code.

Rejected because it inverts the trust model. Runtime fetch means the action’s behavior at any moment is whatever the source serves at that moment, with no local artifact to review. The user cannot read the file they have to know what will run; they have to trust the source served the same bytes they last reviewed. And it introduces a hard runtime dependency on the source’s availability and integrity. Local-first execution is non-negotiable for this trust profile.

Actions as imported library functions (rejected)

Actions are functions exposed by a published library that the developer imports into application code. Aileron loads the library and invokes the functions.

Rejected because it removes the declarative property. An action defined in code is opaque (the reader must read the function body and reason about its behavior); it can take arbitrary runtime branches; it can read state outside its declaration. A declarative action file is auditable from its frontmatter alone — capabilities, connectors, and execution steps are all visible without running anything.

Actions with inter-action dependencies (rejected)

Actions can declare requires.actions = ["[email protected]"] and Aileron resolves a dependency graph at install time, much like a package manager.

Rejected on a cost-benefit basis. The graph would require: version-range resolution, conflict detection between transitively-pulled versions, install ordering, lifecycle hooks for cleanup, partial-failure semantics across the graph, and a debug story when something goes wrong three levels deep. The benefit — reusable building blocks — is already addressed by either letting the agent compose actions in conversation or by writing a new action that exercises the underlying connectors directly. The cost-to-value ratio doesn’t justify the complexity.

Actions inherit the connector’s full capability grant (rejected)

The action file declares which connectors it uses; the runtime lets the action invoke any operation the connector is capable of performing. The connector is the only enforcement boundary.

Rejected because it removes defense in depth. A bug or compromise in an action’s execution path could exercise more of the connector’s grant than intended without any second check. By requiring the action to also declare its capability subset, capability creep is visible (in the TOML diff) and enforced (at the action boundary). The cost — three lines of TOML per connector reference — is trivial.

Built-in catalog of actions in Aileron core (rejected)

Aileron ships with first-party actions for common tasks (“send-email”, “post-to-slack”, “create-calendar-event”) that developers don’t need to install.

Rejected because it ossifies behavior at a layer that should remain flexible. A first-party send-email action implies one canonical authoring style, one default tone, one canonical set of trigger phrases. Real users want their actions to reflect their voice and their preferences. The ShadCN model — install a template, then own and edit the file — is the right shape for this layer. Sources (Hub, GitHub, GitLab, etc.) provide starting points; users customize.

Consequences

For users

  • Actions live in ~/.aileron/actions/, alongside the user’s other Aileron state (vault, store, audit). They are personal capability extensions, not project-coordination state.
  • The set of actions the user has installed is fully determined by reading the files in ~/.aileron/actions/. No hidden registries, no runtime resolution surprises.
  • Customizing an installed action is just editing a file. The source’s template is a starting point; the user’s copy is canonical from install onward.
  • Compound operations are either composed in agent conversation or written as a new action. Either is a normal authoring activity, not a special framework feature.
  • Users who want to back up or sync their actions across machines can put ~/.aileron/actions/ in a personal dotfiles repo. That’s a personal workflow choice, not an Aileron concern.

For action authors

  • An action is a single file. Publishing is making that file available at an FQN — pushing to a GitHub release, a GitLab release, the Aileron Hub, or any future scheme the resolver knows.
  • Publishers choose where to host. The Hub offers discoverability, search, and curation. Self-hosting on github:// or gitlab:// keeps the publisher in full control of release cadence and access policy.
  • Authors do not control how their action is used after install. The user who runs aileron action add owns the resulting file outright.
  • An action that wants new capability or a new connector dependency requires republishing a new template version. The user chooses whether and when to apply the diff.

For Aileron runtime

  • The runtime reads action files from ~/.aileron/actions/ on startup (or on action invocation; either is acceptable). It does not phone home, does not consult a registry, does not authenticate to an external service to load an action.
  • Capability enforcement runs at both boundaries: the connector’s manifest grant (per ADR-0002) and the action’s declared subset. A violation at either boundary terminates the call.
  • The runtime parses the TOML frontmatter and extracts the Markdown body separately; the body becomes the LLM-facing function description when actions are surfaced to the agent.

For sources (Hub, GitHub releases, GitLab releases, etc.)

  • A source is a curated catalog of action templates and connector binaries that publishers push to. It is not a runtime dependency.
  • Action discovery, search, and browse happen on the source’s surface (the Hub provides this natively; GitHub/GitLab provide it through their own browse and search). Installation is a copy operation; the source is not consulted again until the user asks for an update.
  • The Hub validates action templates at publish time: TOML frontmatter parses; declared connectors exist at the named FQN+version+hash; declared capability subsets are valid against the connector’s manifest; Markdown body parses. Other schemes do not enforce validation server-side; install tooling validates locally before writing to the user’s actions directory.

For composition and agent orchestration

  • Compound flows are emergent from agent conversation. The agent calls action A, sees the result, decides whether to call action B. This is the natural mode.
  • Authors who want a specific compound flow as a single tool write a new atomic action that exercises multiple connectors. There is no special “compose two actions” primitive.
  • The atomicity rule keeps the dependency graph at depth 1 forever: actions → connectors → primitive capabilities. No transitive dependency resolution exists in the system.

Open implementation questions (deferred)

  • How does aileron action add resolve missing connector dependencies and walk the user through bindings? — deferred to ADR-0004 and ADR-0007.
  • How does the runtime match agent intent to a specific installed action? — deferred to ADR-0008.
  • How does an action behave when one of its connector calls fails partway through? — deferred to ADR-0010.
  • Will Aileron support a project-level action surface (<project>/actions/) for teams that want to commit shared action sets? — deliberately deferred. v1 is user-level only. The shape of any future project-level surface (precedence rules, merging behavior, conflict resolution) will be ratified when concrete teams ask for it.

Examples

A complete action file (~/.aileron/actions/ship-update.md)

+++
name = "ship-update"
version = "1.0.0"
source = "hub://aileron/[email protected]"

[[requires.connectors]]
name = "github://aileron/slack"
version = "1.2.0"
hash = "sha256:abc123..."
capabilities = ["chat:write", "channels:read"]

[[requires.connectors]]
name = "github://aileron/git"
version = "2.1.0"
hash = "sha256:def456..."
capabilities = ["read"]

[match]
intent = "tell team I shipped"

[[inputs]]
name = "channel"
type = "string"
description = "Slack channel to post the announcement to (e.g. '#engineering')."

[[execute]]
id = "recent_merge"
connector = "github://aileron/git"
op = "read_recent_merge"

[[execute]]
id = "post"
connector = "github://aileron/slack"
op = "post_message"

[execute.inputs]
channel = "${args.channel}"
message = "${recent_merge.summary} → ${recent_merge.pr_url}"
+++

# Ship Update

Posts a "shipped" announcement to a Slack channel with the merged PR link.

## When it fires

Triggered when the user tells their agent things like:

- "tell team I shipped the migration"
- "post a ship update to #engineering"
- "let the team know I merged the PR"

## What it does

1. Reads the most recent merge commit from local git.
2. Extracts the PR URL from the commit body.
3. Formats a message and posts it to the specified Slack channel.

Capability denial at the action boundary

ship-update declares github://aileron/slack with capabilities = ["chat:write", "channels:read"]. At runtime, an [[execute]] step attempts slack.list_users. The Slack connector’s manifest does permit users:read, but the action did not declare it. The runtime denies the call at the action boundary before it reaches the connector:

{
  "error": {
    "class": "capability_denied",
    "boundary": "action",
    "action": "[email protected]",
    "connector": "github://aileron/[email protected]",
    "requested": "users:read",
    "declared_subset": ["chat:write", "channels:read"],
    "audit_id": "audit-9c2a..."
  }
}

The action fails fast and visibly. The developer sees the boundary that denied it (action vs. connector), which makes the fix obvious: declare the capability if it’s intended, or remove the call if it’s not.

Composing two actions

Compound flow: post a ship-update and file a follow-up ticket. Two paths:

Agent-orchestrated (default). The agent calls ship-update, observes the result, then calls file-followup-ticket. No new code is written.

A new atomic action. The user writes ~/.aileron/actions/ship-and-followup.md declaring connectors for both Slack and Linear; the action’s own [[execute]] steps perform the post and the ticket-creation. The new action does not declare a dependency on ship-update or file-followup-ticket. It exists as a sibling action, exercising the same connectors directly.

Either path keeps the action dependency graph flat.