The .axn File

.axn files are ordered Axon action lists. The file shape is the same shape accepted by run, so a recording can be replayed from MCP or with axon run path.axn.

version: 2
args:
  - name: assignee
    type: string
    default: Mitch
  - name: assignee_email
    type: email
    default: mitch@example.com
actions:
  - tool: type
    target: {app: Example, name: form/assignee}
    value: "{{assignee}}"
  - tool: type
    target: {app: Example, name: form/assignee-email}
    value: "{{assignee_email}}"
  - tool: click
    target: {app: Example, name: form/submit}

run stops on the first failed action by default and returns a run result with a trace. AxnRunner and the CLI summary operate on this unwrapped shape; socket and MCP tool-call responses preserve the legacy externally visible {"batch": ...} envelope around it.

Version 2 target policy

Version 2 is a deliberate breaking change. Every interactive element target is an app-scoped semantic name, {app, name}. A recorded action may also carry a locator beside those fields as replay evidence, but a locator without name is not a target. Snapshot handles such as s1:12 are session-local cache keys and are never accepted from .axn files.

Process identity is also runtime-only. A live look records the selected process ID beside its semantic-name evidence, and later actions select that evidence before capturing the application again. This keeps coexisting instances separate and pins replay resolution to the process that was observed, while snapshots and .axn files continue to persist only the application name or bundle identifier. Legacy replay evidence without a runtime process ID must resolve uniquely; Axon does not choose arbitrarily among same-identity processes.

Version 1 files are rejected before the first dispatch. The error identifies the first action whose obsolete target requires attention. Axon does not guess a migration because a snapshot handle contains no durable identity; re-record the workflow or edit it to version 2 with semantic names.

{
  "success": true,
  "dryRun": false,
  "continueOnError": false,
  "trace": [
    { "index": 0, "tool": "type", "success": true },
    { "index": 1, "tool": "type", "success": true },
    { "index": 2, "tool": "click", "success": true }
  ]
}

Locator healing

Replay reports when a recorded locator resolves uniquely but some of its saved evidence has drifted. To review a revised workflow, pass healedPath to the MCP run tool or --healed-path <file> to the CLI:

axon run ./workflow.axn --healed-path ./workflow.healed.axn

Axon writes a new .axn; it never modifies the source workflow. The revised locator is proposed only after it resolves uniquely at no lower confidence than the locator used during the run. Ambiguous resolutions halt healing rather than widening a locator. Evidence that the active resolution path could not evaluate is retained, and frame movement alone does not trigger a revision. Secret-tainted locator values are never written. A dry run reports what it can inspect without writing the healed file.

Treat the output as a review artifact: inspect its locator diff, keep the source under version control, and replay the healed copy before replacing the original.

When both path and actions are supplied, Axon loads the file first and then appends the inline actions. That supports parameterized replays without a second plan language.

.axn parameters live in the top-level args: list. References use {{name}} inside string value, text, and key fields, and all parameters resolve before any action runs. Caller-provided values are passed as argValues over MCP/socket calls or with repeated CLI --arg name=value flags. Declared source: URLs such as env://NAME and op://vault/item/field bind a parameter to a resolver; caller values cannot override sourced args.

type: secret is a handling rule, not a source. Secret-tainted values are sent to the primitive action but are redacted from dry-run params, axn traces, and history records. Prefer source: op://... or source: env://... for secrets; literal CLI --arg values can still be exposed by shell history or process inspection before Axon receives them.

Delivery policy

Every mutating step takes the same optional deliveryPolicy its tool takes. backgroundOnly is the default, so a step that says nothing about delivery will not activate an application, change system focus, move the real pointer, send global keyboard input, or touch the clipboard — it returns a structured refusal instead.

The policy is never inherited. It belongs to the step that carries it. A step that permits foreground delivery says nothing about the next step, and nothing about later runs of the same file. Grant it once, where the run genuinely needs it, and every other step stays in the background.

version: 2
actions:
  # Semantic: sets AXValue and reads it back. No focus, no activation.
  - tool: type
    target: {app: Example, name: form/assignee}
    value: "{{assignee}}"

  # Still backgroundOnly, because the policy above did not carry over.
  - tool: click
    target: {app: Example, name: form/submit}

  # This one shortcut needs the app frontmost, so it opts in explicitly. Axon
  # activates the app, posts the keystroke, and restores the prior app.
  - tool: keyboard
    app: Safari
    key: cmd+shift+p
    deliveryPolicy: foregroundPermitted

  # Back to backgroundOnly.
  - tool: invoke
    target: {app: Example, name: form/submit-menu}
    name: AXPress

Each step's trace result carries the four delivery fields — deliveryPolicy, delivery, dispatchSuccess, and refusal — so a replay shows which rung carried each action. A refused step is a failed step: nothing was dispatched, so no expects postcondition can promote it to success. A step that did dispatch but could not prove its goal is exactly the case expects exists for, and a postcondition that verifies clears the declined escalation it no longer explains.

deliveryPolicy needs no special version-2 syntax: .axn steps retain tool parameters verbatim, and the external {"batch": ...} envelope is untouched.

Metadata

Actions may carry metadata that run strips before dispatch:

version: 2
actions:
  - id: a001
    tool: type
    target:
      app: Safari
      name: form/issue-title
      locator:
        role: AXTextField
        identifier: issue-title
    value: Draft issue title
    expects:
      - id: a001.value.0
        kind: value
        target:
          app: Safari
          name: form/issue-title
          locator:
            role: AXTextField
            identifier: issue-title
        state:
          value:
            equals: Draft issue title
  - id: a002
    tool: keyboard
    app: Safari
    key: Return
    requires:
      - a001.value.0

A fact's matchers live under state, keyed by what the fact is about: value, selected, focused, enabled. A fact's target must include {app, name}. It may attach locator evidence, but is never a snapshot handle or standalone locator.

The pair above is the one case where a workflow asserts its own input back at itself, and it is a dependency guard rather than a postcondition: the requires on the following step is what makes it worth writing. Do not press Return unless the field still holds what was typed.

Supported replay tools are click, type, keyboard, scroll, drag, and invoke. Read tools such as look and find may be kept in history as context and can be included by save(..., includeReads: true), but normal saved workflows omit them.

Derived postconditions

Both workflow producers — save from an agent session and a live user recording — compile expects from a bounded before/after read taken around each action, through the same shared rule set. The reads are targeted — the acted-on element, the app's focused element, the app's window titles — never a full tree capture. Settling has one answer in both paths: two agreeing reads, up to a 150ms budget, before the after-read counts. The agent path pays that wait only for actions likely to cause a transition (click, invoke, keyboard, drag); a live recording pays it on every event, because the wait runs when the user has already moved on, and a passive event tap has no other way to let an effect land.

A recording's before-read is the best a passive tap can do. Clicks and drags read at mouseDown, before the press is delivered to the app; a text burst reads at its first keyDown; a special key reads before the pending text flush, so the flush's own settle wait cannot contaminate the read with the key's effect. Alongside the shared compiler the recorder keeps two producers of its own, both rooted in evidence the compiler cannot see: the changed fact, derived from AX notification evidence rather than a state comparison, and the typed-value dependency guard described above.

A post-action read that never settled derives nothing at all. A button that disables during submission and re-enables after the budget would otherwise be saved as permanently disabled, and a boolean read mid-transition is no more trustworthy than a string one.

The derivation set is deliberately small. Each entry is a direct comparison between the before and after read of one element that has a durable locator, and every comparison needs both sides. An attribute the pre-action read could not reach comes back the same way an attribute that does not exist does, so a missing before side is never read as "it changed" — that would assert state the action may have had nothing to do with. The same applies to the window list: a list that could not be read is not an app with no windows.

Transition Emitted fact
The target gained focus focused, state: {focused: true}
Focus landed on a different element focused on that element
The target's enabled state flipped enabled, state: {enabled: <after>}
The target's value changed value, state: {value: {equals: <after>}}
A selection control's value changed selected, state: {selected: {equals: <after>}}
A window title appeared that was not there before window on {role: AXWindow, title: <new>}

The fact's target locator comes from the post-action read while the action's own target comes from the pre-action read. This matters for any action whose purpose is to mutate its target: Firefox's URL bar exposes an AXDescription only while it is empty, so a fact carrying the pre-action locator would resolve missing and never evaluate its predicate.

Three exclusions apply to every candidate. A candidate that trips one is dropped silently — an action with nothing safe to say is saved with no expects and stays a valid, unverified step.

Do not derive tautological parameter assertions automatically. A candidate is dropped when its string equals, contains, or is contained by any input string (value, text, key) the saved workflow carries, including downstream echoes. This recording-time exclusion prevents a typed value from being treated as proof of the action that typed it. Authored and exported workflows may nevertheless use parameter references inside value, text, or key expectation fields. Replay resolves those references before baseline capture or verification and carries secret taint separately, so credentials cannot enter traces, errors, resolution evidence, or healing proposals.

Never assert a clicked target's own label. A candidate is dropped when its string already appears as the title, value, description, or identifier of the fact target's own locator. Clicking a button labelled Submit and then asserting the button still reads Submit verifies nothing: the locator resolving at all already proved it.

Never assert a secret. A candidate is dropped when it is redacted or when the deterministic redaction rules recognise it.

Beyond those, a candidate is also dropped when the element has no durable locator and when the assertion is empty.

Steps whose target cannot be given a semantic name are not emitted as replayable version-2 actions. The recorder reports a warning instead of preserving an unusable snapshot handle.

CLI

axon run ./workflow.axn
axon run ./workflow.axn --arg assignee=Ada
axon run ./workflow.axn --dry-run
axon run ./workflow.axn --healed-path ./workflow.healed.axn
axon save --path ./workflow.axn
axon save --include-reads