← Files aictrl.devARCHIVED FILE
reference/authoring-guide.md
19.4 KB · Sep 30, 2026 · 22:54 UTC
# AICtrl workflow authoring guide (v2)
The complete authoring reference for `aictrl/workflow/v2` files. This guide is
self-contained: the JSON Schema it describes ships alongside it at
`reference/workflow.schema.json`, and worked, apply-ready examples ship at
`reference/examples/`. Read this before writing any workflow.
The **single structural source of truth** is the bundled
`reference/workflow.schema.json` (JSON Schema, Draft 2020-12). Every field, type,
enum, pattern, and numeric bound below is defined there and enforced at apply
time. When this guide and the schema disagree, the schema wins.
## File format
Always author in **YAML** (comments + readability). The apply-loader accepts both
YAML and JSON; the schema validates the parsed object, so both are equivalent.
Author the file as `.aictrl/workflows/<name>.yaml` in your connected repository —
see SKILL.md for the apply loop.
## Top-level fields
```yaml
schemaVersion: aictrl/workflow/v2 # required for new portable workflows
name: my-workflow # required; kebab-case, unique within org, 1-64 chars
label: My Workflow # optional; human-readable display name
description: What this workflow does # optional
category: code-quality # optional; UI grouping
icon: code # optional; Lucide icon name
failureStrategy: fail-fast # optional; 'fail-fast' (default) | 'continue-on-error'
parameters: [...] # optional; workflow-level inputs (see Parameter types)
nodes: [...] # required; at least one node
edges: [...] # optional; ordering (see Edges)
qualityGates: [...] # optional; manual or auto checkpoints
triggers: [...] # optional; up to 10 file-declared event triggers
```
**No system fields** in the authored file: no `id` (org-level UUID), `version`,
`status`, timestamps, `runCount`, or canvas `position`. These are populated by
the platform on import. Authoring them is a schema error (`additionalProperties`
is `false` at the top level and on every object).
## Parameter types (13 total)
Used for workflow-level `parameters`, `user-input` node `parameters`, and inline
`task` node `parameters`:
| type | CEL type | Notes |
|---|---|---|
| `string` | string | Plain text |
| `text` | string | Multi-line text area |
| `number` | double | Numeric |
| `boolean` | bool | True/false |
| `url` | string | URL-validated string |
| `select` | string | Requires `options: [...]` |
| `multi-select` | list<string> | Requires `options: [...]` |
| `json` | dyn | Arbitrary JSON |
| `repository` | string | Connected repository in owner/name form |
| `pull-request` | string | PR URL |
| `story` | string | Story/ticket URL |
| `image` | string | Image URL or base64 |
| `github-issue` | string | GitHub issue URL |
Parameter schema:
```yaml
- name: my-param # required; kebab-case
label: My Parameter # optional; defaults to name
type: string # required; one of the 12 types above
description: What it is # optional
required: false # optional; default false
default: some-value # optional
options: [a, b, c] # required for select/multi-select; forbidden otherwise
```
`options` is **required** for `select`/`multi-select` (min 1 item) and
**forbidden** on every other type. Both directions are enforced by the schema.
## Node types (7 total)
Every node must have `id` (kebab-case) and `type`. Each type has a required field
and a set of forbidden fields (per-type **field exclusivity**, enforced by the
schema's `allOf` block). Supplying a field that belongs to another node type is a
layer-1 error.
### `task` — define portable skill-backed work inline
```yaml
- id: review
type: task
skill: code-review@1.0.0 # required; pin when a version is resolvable
taskType: code-review # required; executor tool boundary
prompt: Review the current pull request head and return structured findings.
timeoutMinutes: 10 # optional; executor runtime, integer 1-60
parameters:
- { name: pull-request, type: pull-request, required: true }
inputs:
pull-request: { from: input, name: pull-request }
outputs:
findings: json
retry: { maxAttempts: 2, backoffSeconds: 5 }
```
Inline task nodes make the task configuration portable in the workflow file.
Their `inputs` are checked against the in-file `parameters`; their declared
`outputs` may be mapped by downstream nodes. Treat `prompt` as instructions for
the selected skill, not as a way to relax workflow policy or tool boundaries.
`timeoutMinutes` is an optional hard executor bound from 1 to 60 minutes.
### `template` — run a task template
```yaml
- id: review
type: template
template: inline-code-review # required; portable kebab name (not a UUID)
templateVersion: "1.4.0" # optional; pin a specific version
inputs: { ... } # optional; mapped to the template's parameters
when: "..." # optional; CEL skip condition
retry: { ... } # optional; retry policy
outputKey: my-output # optional; override artifact key
```
Forbidden on `template`: `workflow`, `workflowVersion`, `maxIterations`, `until`,
`while`, `onMaxIterations`, `body`, `signalSource`, `timeoutMinutes`,
`checklistItems`, `assignee`, `parameters`.
### `workflow` — invoke a composite workflow
```yaml
- id: run-pipeline
type: workflow
workflow: release-pipeline # required; portable kebab name of another workflow
workflowVersion: "2" # optional
inputs: { ... }
when: "..."
```
Forbidden on `workflow`: `template`, `templateVersion`, `maxIterations`, `until`,
`while`, `onMaxIterations`, `body`, `signalSource`, `timeoutMinutes`,
`checklistItems`, `assignee`, `parameters`.
### `loop` — repeat a body subgraph
```yaml
- id: review-fix
type: loop
maxIterations: 5 # required; hard cap 1-25
until: "last.review.output.maxSeverityRank <= 2" # exit condition (do-while)
# OR:
while: "last.review.output.maxSeverityRank > 2" # continue condition (pre-check)
# Supply exactly one of `until`/`while`, or neither for a fixed-count loop.
onMaxIterations: fail # optional; 'fail' (default) | 'continue' | 'warn'
body:
nodes: [...] # required; subgraph nodes (same types)
edges: [...] # optional
```
`type: loop` is fully supported — the apply-loader accepts loop nodes
and resolves loop-body node refs exactly like any other node. Use
`reference/examples/review-fix-loop.yaml` as the reference.
**Loop bounds** (enforced at load — by the JSON Schema and DAG validation):
- `maxIterations` must be 1-25.
- Max nesting depth: 3 levels.
- The product of all nested `maxIterations` on any path must be <= 1000 (a
normative global iteration budget; the per-loop cap alone does not bound nested
cost — 25³ ≈ 15.6k otherwise).
**Loop CEL reference rules**:
- Inside a loop body, sibling nodes are referenced with the bare
`<nodeId>.output.*` form.
- Loop-level `until`/`while` reference the just-completed pass via
`last.<nodeId>.output.*`. CEL reserves `loop` and `while` as identifiers, so the
loop context binds as `last` and `iteration` (not `loop`).
- `iteration` is the current pass number (1-indexed).
Forbidden on `loop`: `template`, `templateVersion`, `workflow`, `workflowVersion`,
`signalSource`, `timeoutMinutes`, `checklistItems`, `assignee`, `parameters`.
Supplying both `until` and `while` together is also forbidden (exactly one or
neither).
### `wait` — pause until an external signal
```yaml
- id: await-ci
type: wait
signalSource: ci-completed # required; opaque signal identifier
timeoutMinutes: 60 # optional; max wait time
when: "..."
```
`signalSource` charset: `[A-Za-z0-9][A-Za-z0-9._-]{0,255}`. No colons or slashes,
so it can never be a URL/host the platform fetches (SSRF prevention).
Forbidden on `wait`: `template`, `templateVersion`, `workflow`, `workflowVersion`,
`maxIterations`, `until`, `while`, `onMaxIterations`, `body`, `checklistItems`,
`assignee`, `parameters`.
### `manual` — human checklist gate
```yaml
- id: qa-sign-off
type: manual
checklistItems:
- "Smoke test passed"
- "Changelog updated"
assignee: alice # optional
when: "..."
```
`checklistItems` requires at least one item.
Forbidden on `manual`: `template`, `templateVersion`, `workflow`,
`workflowVersion`, `maxIterations`, `until`, `while`, `onMaxIterations`, `body`,
`signalSource`, `timeoutMinutes`, `parameters`.
### `user-input` — collect form input at run time
```yaml
- id: collect-params
type: user-input
parameters:
- name: target-env
type: select
options: [staging, production]
required: true
when: "..."
```
Forbidden on `user-input`: `template`, `templateVersion`, `workflow`,
`workflowVersion`, `maxIterations`, `until`, `while`, `onMaxIterations`, `body`,
`signalSource`, `timeoutMinutes`, `checklistItems`, `assignee`.
## Input mappings
Every node `inputs` entry maps a parameter name to one of three mapping kinds
(the schema's `inputMapping` `oneOf` — exactly one shape per entry):
### Static value
```yaml
inputs:
title: { value: "High-severity findings in PR" }
```
### Workflow parameter reference
```yaml
inputs:
pr-url: { from: input, name: pr-url }
```
### Upstream node output reference
```yaml
# Full artifact (no extract)
inputs:
body: { from: node, node: summarize }
# JSON-path extract (shorthand string form)
inputs:
findings: { from: node, node: review, extract: "$.findings[*]" }
# JSON-path extract (object form — identical result)
inputs:
findings:
from: node
node: review
extract:
method: json-path
expression: "$.findings[*]"
# Full artifact (object form — identical to omitting extract)
inputs:
data:
from: node
node: review
extract:
method: full
```
`extract` in v1 supports `full` and `json-path` only. `regex` and `template`
extract methods are deferred (they are ReDoS/injection surfaces on untrusted,
PR-derived content) and are rejected at apply time.
## CEL expressions (`when`, `until`, `while`, gate `condition`)
Conditions are written in **CEL** (Common Expression Language). The essentials
below are bundled here so you never need an external reference.
**Binding model** — names available in an expression:
- `<paramName>` — a workflow input by its CEL-safe name.
- `input['<param-name>']` — any workflow input (the `dyn` escape hatch; use this
form for kebab-case names that are not valid bare identifiers).
- `<nodeId>.output.<field>` — an upstream node's output (typed if the referenced
template declares an output schema; otherwise `dyn`).
- `nodes['<node-id>'].output.<field>` — any node output (the `dyn` escape hatch
for node ids that are not valid bare identifiers).
- Inside a loop body: `iteration` (current pass, 1-indexed) and
`last.<nodeId>.output.<field>` (previous pass outputs).
**CEL reserved words** — cannot be used as bare identifiers: `as`, `break`,
`const`, `continue`, `else`, `false`, `for`, `function`, `if`, `import`, `in`,
`let`, `loop`, `namespace`, `null`, `package`, `return`, `true`, `var`, `void`,
`while`. When a parameter or node id clashes with one of these (or is not a valid
bare identifier), use the indexed `input[...]` / `nodes[...]` form.
**Numeric ranks for ordered-enum comparisons** — branch on the numeric rank, not
the string. Severity ranks are:
| severity | rank |
|---|---|
| info | 0 |
| low | 1 |
| medium | 2 |
| high | 3 |
| critical | 4 |
Write `review.output.maxSeverityRank > 2` (high or critical), **not**
`review.output.maxSeverity > "medium"`. ("minor" is a tool-level severity that
maps to storage `medium` — never use it in a condition.)
**Expression rules**:
- Max length: 1024 characters.
- The result must be a **boolean**. A static check rejects a non-boolean
expression; the runtime rejects a non-boolean result.
- `dyn` expressions that reference untyped node outputs are accepted at check
time but must evaluate to a boolean at runtime.
## Retry policy
```yaml
retry:
maxRetries: 2 # required; 0-10
backoffMs: 2000 # required; milliseconds between retries (>= 0)
backoffMultiplier: 2.0 # optional; multiplier applied to backoffMs each retry (0-10)
maxBackoffMs: 30000 # optional; cap on exponential backoff
```
## Edges (ordering)
Edges declare execution order; **data flow lives in node `inputs`, not in edges.**
An edge from `A` to `B` means "B runs after A completes."
```yaml
edges:
- { from: review, to: summarize }
- { from: summarize, to: open-issue }
```
The DAG must be acyclic (enforced). Disconnected nodes run in parallel waves.
## Quality gates
```yaml
qualityGates:
- afterWave: 1 # required; integer >= 1 (wave number after which this gate runs)
type: manual # required; 'manual' | 'auto'
description: "Reviewer confirms findings before summary + triage." # optional
- afterWave: 2
type: auto
condition: "review.output.maxSeverityRank <= 2" # required for type:auto; CEL boolean
description: "Auto-approve when severity is low or medium"
```
`condition` is **required** when `type: auto` and is a CEL boolean.
## Triggers (optional)
A workflow may declare up to **10** file triggers. Five trigger types are supported:
`label`, `comment`, `pr-ready`, `pr-opened`, and `chat-message`.
```yaml
# Fire when a matching label is added to a PR:
triggers:
- type: label
on: pull-request
label: needs-review # exact, case-sensitive GitHub label name
inputs:
pr-url: "$.pull_request.html_url" # JSONPath into the webhook payload
# Fire when a PR comment's first token is an exact slash command:
- type: comment
on: pull-request
command: /review-fix # exact, case-sensitive; matched as the first token
inputs:
pr-url: "$.issue.pull_request.url"
# Fire when a PR is opened, including as a draft:
- type: pr-opened
on: pull-request
inputs:
pr-url: "$.pull_request.html_url"
# Fire when a PR becomes ready, or is opened as non-draft:
- type: pr-ready
on: pull-request
inputs:
pr-url: "$.pull_request.html_url"
# Fire from an exact Telegram slash command:
- type: chat-message
provider: telegram
command: /run-review
chats: [123456789] # optional allowlist; max 100 ids
acceptPhotos: true # optional; default true
sendConfirmation: true # optional; default true
sendCompletion: false # optional; default false
inputs:
prompt:
from: message
field: text
```
- `label`, `comment`, `pr-ready`, and `pr-opened` require `on: pull-request`.
- `pr-opened` fires for every newly opened PR, including drafts. `pr-ready` fires
when a draft becomes ready or when a PR is opened non-draft.
- For GitHub triggers, `inputs` maps workflow parameter names to JSONPath
expressions (each must start with `$`) evaluated against the webhook payload;
max 20 entries.
- `chat-message` has no `on` field. Its required fields are `provider` and
`command`; `command` must be an exact, case-sensitive slash command. Its
`inputs` values are `{ from, field }` mappings, where `from` is `message`,
`channel_post`, or `interaction`, and `field` is a dot-path rather than JSONPath.
Telegram has runtime support; Slack and Discord definitions can be stored but
do not yet fire workflows.
- **Security (enforced at event time):** only events from a repo collaborator with
write/admin permission fire a trigger; bot comments and comment edits are
ignored for GitHub triggers. This is a platform guarantee, not something you
configure in the file.
## Portable references
Use the **kebab name** (`name` field of the template or workflow), not an internal
UUID. The loader resolves the name to a UUID, org-scoped, at apply time. An unknown
or archived reference **fails the apply** (fail-closed, supply-chain safety).
```yaml
template: inline-code-review # correct — portable
template: a1b2c3d4-... # wrong — a UUID is not portable across environments
```
## Validation layers
Three layers run at apply time, **before any node executes**:
1. **Layer 1 — JSON Schema** (`reference/workflow.schema.json`): structural
validity, enums, `minItems`, numeric ranges (e.g. loop `maxIterations` 1-25),
and per-type field exclusivity.
2. **Layer 2 — per-template validation** (load time): each node's `template`
(+ `templateVersion`) is resolved and its `inputs` are validated against that
template's own parameter schema — required inputs present, unknown inputs
rejected, static `value`s type-checked. **Reference resolution is org-scoped
and fail-closed:** a `template`/`workflow` name only ever resolves to an
*active* record in the authoring org (never another org's, never an archived
one), and an unresolvable name fails the apply before any node runs.
3. **DAG validation**: no cycles, referenced-node existence, loop `maxIterations`
range, loop nesting depth <= 3, global iteration product <= 1000, and the CEL
condition static check.
**Check it offline first.** The bundled `validate.mjs` (skill root) runs Layer 1
against `reference/workflow.schema.json` **plus** the static parts of DAG validation
(duplicate ids, dangling edges, cycles, loop depth, the maxIterations product bound)
— everything checkable without your org. Run it before you apply:
`node path/to/create-workflow/validate.mjs .aictrl/workflows/<name>.yaml`
(needs dev-only `ajv ajv-formats js-yaml`). It **cannot** confirm Layer 2 (does the
referenced template exist in *your* org) or CEL runtime semantics — the apply is the
definitive gate for those.
## Constructs that are rejected at apply time
The following are rejected:
- **Nested loops beyond depth 3** — rejected by DAG validation.
- **A `pause` node type** — not in the schema (use `wait` for a signal or `manual`
for a human gate).
- **`regex` and `template` extract methods** — deferred until a linear-time
matcher and a non-evaluating template grammar land; only `full` and `json-path`
are supported.
- **A `task` node under `schemaVersion: aictrl/workflow/v1`** — inline tasks require
workflow v2. New portable workflows should use v2.
## Authoring checklist
Before submitting a workflow file for apply:
- [ ] `schemaVersion: aictrl/workflow/v2` for new portable workflows
- [ ] `name` is kebab-case, lowercase, 1-64 characters
- [ ] No system fields (`id`, `version`, `status`, timestamps, `position`)
- [ ] Every `template` node has a `template` field (kebab name, no UUID)
- [ ] Every `task` node has a version-pinned `skill` when resolvable, `taskType`,
`prompt`, typed `parameters`, and declared `outputs` used downstream
- [ ] Every `loop` node has `maxIterations` and `body`; `until` and `while` are mutually exclusive
- [ ] Every `wait` node has `signalSource` (no colons or slashes)
- [ ] Every `manual` node has at least one `checklistItems` entry
- [ ] Every `user-input` node has `parameters`
- [ ] CEL expressions are boolean; no string/number results
- [ ] `select`/`multi-select` parameters have `options`; other types must NOT have `options`
- [ ] Input mappings use `{ value: ... }`, `{ from: input, name: ... }`, or `{ from: node, node: ... }`
- [ ] No `regex` or `template` extract methods
- [ ] Loop nesting <= 3; product of nested `maxIterations` <= 1000
- [ ] Portable refs (kebab names) for `template:` and `workflow:`
- [ ] **`node validate.mjs <file>` exits 0** (layer-1 schema + static DAG checks)
- [ ] At most 10 entries in `triggers:`; each matches one of the five trigger shapes
SHA-256: 5dccf88e2662c97626bfd85208752b6c977f08f9224ab779bd6bc37fd5a063f6