← Back to Documentation

User-Defined Actions

Reusable logic written in ARO itself — a feature set that any other feature set can call by name, and get a value back from.

A feature set that anything can call

Give a feature set the business activity Action and it becomes callable application-wide as Application.<Name>. That is the whole mechanism. Use it instead of writing a plugin, or hopping through the event bus, when what you want is reusable logic with a return value.

(DoubleValue: Action takes <number>) {
    Extract the <n> from the <input: number>.
    Compute the <doubled> from <n> * 2.
    Return an <OK: status> with { doubled: <doubled> }.
}

Call it from anywhere, with the same shape a plugin action uses:

(* `takes <number>` is sugar for one positional argument *)
Application.DoubleValue the <d> from 5.
Extract the <doubled> from the <d: doubled>.   (* 10 *)

(* The object form always works *)
Application.DoubleValue the <second> with { number: 7 }.
Extract the <second-doubled> from the <second: doubled>.   (* 14 *)

Arguments

takes <name> declares a single positional argument, which arrives as input.<name>. Without it, callers pass an object literal through with and the action pulls fields off <input>:

(SumAndDouble: Action) {
    Extract the <a> from the <input: a>.
    Extract the <b> from the <input: b>.
    Compute the <sum> from <a> + <b>.

    (* Actions compose — this one calls the one above *)
    Application.DoubleValue the <inner> from <sum>.
    Extract the <answer> from the <inner: doubled>.

    Return an <OK: status> with { value: <answer> }.
}

At the call site the composed action looks like any other:

Application.SumAndDouble the <res> with { a: 3, b: 4 }.
Extract the <value> from the <res: value>.   (* 14 *)

What the callee can see

A callee sees its own bindings and application-level published symbols — never the caller's locals. Lookup cost therefore does not grow with call depth. Field values keep their shape across the boundary: return a list and the caller gets a list it can measure and iterate, not a rendering of one.

Recursion

Recursion has no depth limit. What it costs depends on the shape you write.

A call in tail position — the final statement is an unguarded Return … with <r>. forwarding the call's result untouched — reuses the frame it is already in, so depth costs nothing at all:

(SumTo: Action) {
    Extract the <n> from the <input: n>.
    Extract the <acc> from the <input: acc>.
    Return an <OK: status> with { total: <acc> } when <n> <= 0.

    Compute the <next> from <n> - 1.
    Compute the <running> from <acc> + <n>.

    (* Tail call: the result is handed straight back, so this frame is reused *)
    Application.SumTo the <r> with { n: <next>, acc: <running> }.
    Return an <OK: status> with <r>.
}

Any other recursion keeps its frames on the heap, so its depth is bounded by memory rather than by a stack. Factorial is the standard example — the frame still has to multiply the callee's result, so each level stays live until the one below it returns:

(Factorial: Action takes <n>) {
    Extract the <v> from the <input: n>.
    Return an <OK: status> with { value: 1 } when <v> <= 1.

    Compute the <next> from <v> - 1.
    Application.Factorial the <inner> from <next>.
    Extract the <sub> from the <inner: value>.
    Compute the <value> from <v> * <sub>.
    Return an <OK: status> with { value: <value> }.
}

Guard rails

WhatBehaviour
aro checkWarns when every path through an action reaches a call before a Return — direct or mutual cycles alike.
ARO_MAX_CALL_DEPTHA runaway recursion stops at 50 000 live frames with an error naming the call chain, instead of an OOM kill. 0 disables the check.
Unknown actionCalling an Application.<Name> that no feature set defines is a check-time error, not a runtime surprise.

Learn More

The full specification is ARO-0081: User-Defined Actions. Examples/UserDefinedActions and Examples/RecursiveActions are runnable. For actions written in Swift, Rust, C or Python instead, see Writing Extensions.