paxc → an introduction
pax: an introduction
A Power Automate flow is code. The designer just won't let you treat it that way — no diff, no comments, no way to read a whole flow at once, and every edit buried three cards deep in a web form. pax is that same flow as a text file you can read top to bottom. You write terse, readable source; paxc compiles it to the exact definition.json Power Automate expects; and a companion interpreter, paxr, runs the same source on your machine so you can see what a flow does before it ever touches the cloud.
This page builds one flow from a single line up to a scheduled job that loops over a list of tasks and emails you a digest. Each section adds one idea and one runnable example. Nothing here is a toy fragment: every snippet compiles, every output shown is real, and the whole thing imports and runs in a live tenant. By the end you'll have written variables, string-building, a loop, a condition, a real connector, and a schedule — and you'll have version-controlled the lot.
Why write flows as text
Open any non-trivial flow in the Power Automate designer and the problems show up fast. You can't see the whole thing — you scroll a column of cards and expand them one at a time. You can't diff it, so “what changed since last week” is a question the tool simply cannot answer. You can't review it in a pull request, can't copy a working pattern from one flow into another without rebuilding it click by click, and can't leave a note for the next person beyond the action names themselves.
And you can't comment something out. Every developer's most basic move — disable this step for a second, run it, put it back — has no equivalent in the designer. You delete the action and rebuild it, or you don't test at all.
pax gives all of that back, because the flow is just text. A flow is a .pax file. Reading it is reading a file. Changing it is an edit you can diff. Disabling a step is two slashes:
foreach task in tasks {
total += 1
// send_reminder(task) ← commented out; the rest still runs
}
That last point sounds small and isn't. Being able to neutralize the one action that sends email — while every surrounding line keeps running — is what makes a flow safe to test.
The trade pax makes is deliberate. It owns the programmable parts of a flow: variables, control flow, expressions, the order things run in. The platform-specific parts — the SharePoint connector, the Outlook send, anything with a Microsoft API behind it — live in small JSON files next to your source, and paxc drops them into the compiled flow untouched. You write logic in pax and keep connectors as data. The split is the whole design, and it'll make sense the first time you use a real connector in the sections that follow.
Install
You need two binaries: paxc (the compiler) and paxr (the interpreter that runs flows locally). They ship together for Linux, macOS, and Windows.
On Debian or Ubuntu, add the Excelano apt repository once and install — apt upgrade then keeps both current:
$ curl -fsSL https://excelano.com/apt/setup.sh | sudo sh
$ sudo apt install paxc
On macOS, Windows, or any other Linux, the paxc install page has a one-line installer that downloads the right tarball, checks it, and drops both binaries into place, plus direct tarball links on the GitHub releases page.
Confirm both are on your path:
$ paxc --version
paxc 3.7.3
$ paxr --version
paxr 3.7.3
Any 3.x version will do for this tutorial.
Your first flow
Make a folder for the flow and put one line in a file called hello.pax:
$ mkdir hello && cd hello
$ cat > hello.pax
let greeting = "Hello from pax!"
debug(greeting)
Two statements. let greeting = "..." binds a value to a name. debug(greeting) prints that name and its value — it's a local diagnostic, and we'll see in a moment that it exists only for paxr and never reaches the compiled flow.
Run it locally first. This is the fast loop — no cloud, no import, just the interpreter:
$ paxr hello.pax
debug: greeting="Hello from pax!" at line 2
end state:
greeting (let) = "Hello from pax!"
The first line is your debug call firing, with the source line it came from. Everything after end state: is paxr's dump of every binding in the flow and the value it settled on — here, the single let named greeting. On a real flow this dump is how you check that variables ended up where you expected without leaving your terminal.
Now compile the same source for Power Automate:
$ paxc --target pa-legacy --name hello --out hello.zip hello.pax
wrote hello.zip
note: dropped 1 debug() statement
--target pa-legacy asks for a legacy-format import package; --name sets the flow's display name; --out names the zip. The note on the second line is paxc telling you it stripped your debug call — debug is a paxr-only diagnostic, so it has no place in the deployed flow. One source file, two honest executions: paxr ran it locally, paxc turned it into something Power Automate can run, and each told you exactly what it did with the debug line.
That zip holds the flow definition Power Automate would otherwise have made you build by hand. The compiled actions block is a single Compose:
"actions": {
"Compose_greeting": {
"type": "Compose",
"inputs": "Hello from pax!",
"runAfter": {}
}
}
Your one-line let became a Compose action named Compose_greeting — that's the pattern: a let binding in pax is a Compose action in Power Automate. "runAfter": {} means it runs first, right after the trigger. You never wrote a trigger, so paxc gave you the default: a manual “Button” trigger you fire by hand. That's perfect for a first import.
Import and run it
In the Power Automate portal, go to My flows → Import → Import Package (Legacy) and upload hello.zip. Accept the import, open the flow, and run it. In the run history, open the Compose_greeting step and you'll see its output: Hello from pax! — the same string paxr showed you locally, now produced in the cloud.
That's the full loop in miniature: write text, run it locally with paxr, compile it with paxc, import, run. Every section from here just makes the flow do more — the loop stays exactly this.
A real connector: send an email
hello composes a string and stops. Real flows reach out — they send mail, write to SharePoint, call an API. Those are connectors, and pax deliberately doesn't model them. A connector's shape is Power Automate's to define and Microsoft's to change; pinning it into the language would mean chasing every connector update forever. So pax draws a line: it owns logic, and a connector is data that lives in a JSON file next to your source. This section sends the greeting as an email through Office 365 Outlook, and that file convention is the whole trick.
You need the connector's JSON, and the surest way to get it exactly right is to let Power Automate write it for you. In the designer, open the hello flow and add a step: search for Office 365 Outlook, choose Send an email (V2), and fill in the fields — a To address, a Subject, and a line of Body. Don't overthink the values; you'll see in a moment that the body can pull from the flow. With the action selected, open its Code view and copy the JSON it shows. That JSON is the connector, expressed exactly the way paxc wants it.
Save it next to your source as pa/Send_an_email.json. Here's the Code view in full, designer quirks and all — the parts paxc reconciles for you at compile time are the host.connection reference and the runAfter, which we'll come back to:
{
"type": "OpenApiConnection",
"inputs": {
"parameters": {
"emailMessage/To": "you@example.com",
"emailMessage/Subject": "Hello from pax",
"emailMessage/Body": "<p class=\"editor-paragraph\">@{outputs('Compose_greeting')}</p>",
"emailMessage/Importance": "Normal"
},
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_office365",
"connection": "shared_office365",
"operationId": "SendEmailV2"
}
},
"runAfter": {
"Compose_greeting": [
"SUCCEEDED"
]
}
}
Two things in there are worth a look. The host block names the connector — the shared_office365 connection and the SendEmailV2 operation — and is identical in every tenant, so it's safe to read and safe to share. And the body isn't a fixed string: buried in the designer's own <p class="editor-paragraph"> markup is @{outputs('Compose_greeting')}, a Power Automate expression that pulls the value of your greeting binding, because a let compiles to a Compose named Compose_greeting. Inside a pa/ file you write Power Automate's expression syntax, not pax — this is the one place the two meet. paxc carries the connector body through untouched, editor markup and all; what it reconciles is the wiring around it.
A connector also needs a connection to run against. Power Automate tracks that as a top-level map, and paxc reads it from pa/connectionReferences.json:
{
"shared_office365": {
"id": "/providers/Microsoft.PowerApps/apis/shared_office365",
"apiName": "office365"
}
}
That's the connection the importer will ask you to bind to your own Office 365 account when you bring the flow in. Now reference the connector from your source. Drop the debug line, give the greeting a fuller message for the email's sake, and add the connector call — hello.pax becomes:
let greeting = "Hello World, sent from a pax flow."
pa Send_an_email
The pa Send_an_email line is the reference: paxc reads pa/Send_an_email.json at compile time and drops it into the flow under that name. Run it locally first, the same fast loop as before:
$ paxr hello.pax
<skipping pa action "Send_an_email">
end state:
greeting (let) = "Hello World, sent from a pax flow."
paxr can't actually send mail — it has no live connection — so it tells you it skipped the connector and runs everything around it. That's the same property that makes a real flow safe to test: the logic runs, the side effect doesn't. Now compile:
$ paxc --target pa-legacy --name hello-email --out hello-email.zip hello.pax
wrote hello-email.zip
It's worth opening the compiled action to see what paxc did with the file you pasted. The Send_an_email action in the package isn't byte-for-byte what you wrote:
"Send_an_email": {
"type": "OpenApiConnection",
"inputs": {
"parameters": {
"emailMessage/To": "you@example.com",
"emailMessage/Subject": "Hello from pax",
"emailMessage/Body": "<p class=\"editor-paragraph\">@{outputs('Compose_greeting')}</p>",
"emailMessage/Importance": "Normal"
},
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_office365",
"operationId": "SendEmailV2",
"connectionName": "shared_office365",
"connectionReferenceName": "shared_office365"
}
},
"runAfter": {
"Compose_greeting": [ "Succeeded" ]
}
}
Two things changed, and each is paxc doing work you'd otherwise do by hand. The single connection field became the pair Power Automate's importer actually requires: connectionName for the run-time validator and connectionReferenceName for the import-time validator, both filled from your connection map so the action binds to the right connection. Miss that rename and the import fails outright. And the runAfter now says the email runs after Compose_greeting succeeds, because that's the order your two statements sit in the source — you never wired the dependency; source order is the dependency. The parameters, editor markup and all, pass through exactly as you pasted them. Write logic in order, keep the connector as data, and let the compiler reconcile the wiring.
Import hello-email.zip the same way as before, map the Office 365 connection when prompted, and run it. The email arrives, its body carrying the very string paxr printed for you locally. That's a connector start to finish: the designer wrote the JSON, a file held it, source order sequenced it, and the same flow that ran on your machine now runs in the cloud.
Building the body from parts
A greeting is a fixed string, and most messages worth sending aren't. They're assembled — a label, a value, a line per item — joined into something readable. pax joins strings with &, the concatenation operator: a string on each side, the two stuck together, chained as far as you need.
From here the running example is a daily task digest: a flow that takes a list of tasks and emails them to you each morning. It's a new flow, so start it in a fresh folder with the source in digest.pax — and copy your pa/ folder across, so the Send_an_email.json and connectionReferences.json you already made come with it. Build the body from a label and a single task:
let task = "Renew the excelano.com certificate"
let summary = "Today's task: " & task
pa Send_an_email
That connector's body still points at the greeting. Leave it for now — you'll aim it at the digest in one step later, when you assemble the flow to compile, and paxr skips the connector anyway. Run the source locally:
$ paxr digest.pax
<skipping pa action "Send_an_email">
end state:
task (let) = "Renew the excelano.com certificate"
summary (let) = "Today's task: Renew the excelano.com certificate"
Two bindings, each with the value it settled on. summary is the label and the task joined into one string — that's the whole of what & does. The interesting part is what it compiled to:
"Compose_summary": {
"type": "Compose",
"inputs": "Today's task: @{outputs('Compose_task')}",
"runAfter": {
"Compose_task": [ "Succeeded" ]
}
}
The & is gone. pax folded "Today's task: " & task into a single Power Automate string with the other Compose interpolated in as @{outputs('Compose_task')}. That's the rule: pax's & becomes Power Automate's @{...} interpolation, and a chain of them becomes one string with each reference dropped into place. You write concatenation and get interpolation, which is the shape Power Automate actually wants.
A loop: the digest takes shape
One task isn't a digest. You have a list, and you want a line for each. That's two new ideas at once: an array to hold the list, and a loop to walk it.
An array is a var like any other, written with JSON-style brackets. And to build the body up one line at a time you need a string you can keep adding to, which means summary can no longer be a let. A let is a single immutable Compose — you can read it but never reassign it. Accumulating across a loop needs a mutable variable, so summary becomes a var, and the loop appends to it with &=:
var tasks: array = [
"Renew the excelano.com certificate",
"Review the open paxc pull request",
"Back up the production database",
]
var summary: string = ""
foreach task in tasks {
summary &= "- " & task & "\n"
}
pa Send_an_email
foreach task in tasks walks the array, binding each element to task in turn. summary &= "- " & task & "\n" appends a dash, the task, and a newline to summary on every pass — &= is append-in-place, and the & inside it is the same concatenation from the last section. Run it with the verbose flag (-v) to watch the body come together one iteration at a time:
$ paxr -v digest.pax
init tasks = ["Renew the excelano.com certificate","Review the open paxc pull request","Back up the production database"]
init summary = ""
foreach task (3 items)
iter[0] task = "Renew the excelano.com certificate"
append_string summary = "- Renew the excelano.com certificate\n"
iter[1] task = "Review the open paxc pull request"
append_string summary = "- Renew the excelano.com certificate\n- Review the open paxc pull request\n"
iter[2] task = "Back up the production database"
append_string summary = "- Renew the excelano.com certificate\n- Review the open paxc pull request\n- Back up the production database\n"
<skipping pa action "Send_an_email">
end state:
tasks (var array) = [
"Renew the excelano.com certificate",
"Review the open paxc pull request",
"Back up the production database"
]
summary (var string) = "- Renew the excelano.com certificate\n- Review the open paxc pull request\n- Back up the production database\n"
Each iter[N] line shows the task for that pass, and the append_string line under it shows summary one line longer than before. By the end it holds all three tasks, newline-separated. That trace is the loop's whole behavior, visible without leaving your terminal.
Here's what the loop itself compiles to:
"task": {
"type": "Foreach",
"foreach": "@variables('tasks')",
"actions": {
"Append_to_summary": {
"type": "AppendToStringVariable",
"inputs": {
"name": "summary",
"value": "- @{items('task')}\n"
},
"runAfter": {}
}
},
"runAfter": {
"Initialize_summary": [ "Succeeded" ]
}
}
The foreach became Power Automate's “Apply to each” (its action type is Foreach), iterating @variables('tasks'). The &= inside became an AppendToStringVariable step, and the iterator task is referenced as @{items('task')}, Power Automate's way of naming the current element. One loop in pax, the matching loop action in the flow, and the same three-line digest either way.
A condition: send only when there's news
The flow sends every morning whether or not there's anything to send. An empty task list should mean no email. That needs a count and a check: count the tasks, and send only when the count is above zero.
length() is one of Power Automate's expression functions, and pax passes function calls straight through to it. length(tasks) returns the number of elements. Capture it in a let — it's a derived value you read, not something you mutate, so a Compose is the right home — and guard the send with an if:
let count = length(tasks)
if count > 0 {
pa Send_an_email
}
With that added, the full source runs and reports the count alongside everything else:
$ paxr digest.pax
<skipping pa action "Send_an_email">
end state:
tasks (var array) = [
"Renew the excelano.com certificate",
"Review the open paxc pull request",
"Back up the production database"
]
summary (var string) = "- Renew the excelano.com certificate\n- Review the open paxc pull request\n- Back up the production database\n"
count (let) = 3
count is 3, so the condition is true and the email would send. The two pieces compile to two actions — a Compose for the count, and a Condition for the if (the email action shown collapsed below; it's the same connector body, unchanged):
"Compose_count": {
"type": "Compose",
"inputs": "@length(variables('tasks'))",
"runAfter": {
"task": [ "Succeeded" ]
}
}
"Condition": {
"type": "If",
"expression": {
"and": [
{ "greater": [ "@outputs('Compose_count')", 0 ] }
]
},
"actions": {
"Send_an_email": { ... }
},
"else": { "actions": {} },
"runAfter": {
"Compose_count": [ "Succeeded" ]
}
}
length(tasks) became @length(variables('tasks')), the function passed straight through. The if became a Power Automate Condition (action type If), and count > 0 became its expression in PA's structured form: a greater test against zero, wrapped in the and array PA uses to hold conditions. The email action moved inside the true branch's actions, exactly where pa Send_an_email sat inside the if in your source; the else is empty because you wrote no else. Source structure in, flow structure out.
The count is good for more than the guard. Compose_count is readable from anywhere downstream, so you'll also give it to the email subject when you assemble the flow — the digest announcing its own size before you open it.
A schedule: run it every morning
Everything so far has run on a manual trigger — the Button trigger paxc gives you when you don't ask for anything else. A digest should arrive on its own each morning, which means a Recurrence trigger.
Triggers are file-based in pax. paxc looks in the pa/ folder for a single *.trigger.json file at compile time; the filename's stem becomes the trigger's key, and the file's contents drop into the flow verbatim. The file matches Power Automate's Code view exactly, same as a connector. Save a daily schedule as pa/Recurrence.trigger.json:
{
"type": "Recurrence",
"recurrence": {
"frequency": "Day",
"interval": 1
}
}
The source doesn't change at all — the trigger lives entirely in that file. paxr never executes triggers, so a scheduled flow runs through the interpreter exactly like a manual one; nothing about the local run differs.
One file still needs updating before you compile: pa/Send_an_email.json. It has sat untouched since the connector section, still addressed to the greeting flow, so point it at the digest now — two edits. The body should read the summary variable inside a <pre> block, so its newlines render as separate lines; and the subject should show the count. In full, the file becomes:
{
"type": "OpenApiConnection",
"inputs": {
"parameters": {
"emailMessage/To": "you@example.com",
"emailMessage/Subject": "@{outputs('Compose_count')} tasks for today",
"emailMessage/Body": "<pre>@{variables('summary')}</pre>",
"emailMessage/Importance": "Normal"
},
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_office365",
"connection": "shared_office365",
"operationId": "SendEmailV2"
}
},
"runAfter": {}
}
Two lines differ from what the designer first handed you: the body now reads @{variables('summary')} inside a <pre>, and the subject reads @{outputs('Compose_count')}. This is the step that's easy to skip — if the body still points at @{outputs('Compose_greeting')}, the import fails with InvalidTemplate, because that Compose belongs to the old hello flow and doesn't exist here. (The runAfter value is immaterial; paxc rewrites it from source order, as you saw in the connector section, so leave it however the designer left it.)
Now compile the finished flow into an import package:
$ paxc --target pa-legacy --name "Daily task digest" --out daily-digest.zip digest.pax
wrote daily-digest.zip
In the compiled flow, the default Button trigger is gone, replaced by the Recurrence you supplied:
"triggers": {
"Recurrence": {
"type": "Recurrence",
"recurrence": {
"frequency": "Day",
"interval": 1
}
}
}
Import daily-digest.zip the way you imported the others, map the Office 365 connection when prompted, and the flow runs itself once a day. That's the whole digest: a list, a loop that builds the body, a count that guards the send, a real connector, and a schedule — all of it in one readable source file plus a handful of JSON connector and trigger files.
Bring a flow back: export and decode
One claim has been running under this whole tutorial: a flow is just text. The real test of that is the round trip — take a compiled flow, the kind Power Automate hands you when you export, and turn it back into pax you can read. That's paxc --decode.
You already have a compiled flow: the daily-digest.zip you just built is the same legacy-package shape Power Automate produces from its Export path. Decode it:
$ paxc --decode daily-digest.zip
note: action `Send_an_email` (type OpenApiConnection) not decoded natively; emitted as pa block
wrote daily-digest/daily-digest.pax
wrote daily-digest/pa/Recurrence.trigger.json
wrote daily-digest/pa/connectionReferences.json
wrote daily-digest/pa/Send_an_email.json
wrote daily-digest/pa/flow.json
The note is paxc being honest about one action. Connectors don't have a pax form — they're data, as the connector section laid out — so Send_an_email comes back as a pa block, its JSON written to pa/Send_an_email.json byte-for-byte. That's the lossless half of the design: anything pax doesn't model natively returns exactly as it went in. Everything else decoded to source:
// Decoded from PA flow. Trigger: pa/Recurrence.trigger.json
var tasks: array = ["Renew the excelano.com certificate", "Review the open paxc pull request", "Back up the production database"]
var summary: string = ""
foreach task in tasks {
summary &= ("- " & task & "\n")
}
let count = length(tasks)
if (count > 0) {
pa Send_an_email
}
That's the flow you built, read back out of the compiled package. The array, the loop, the &= append, the length() count, the if guard — every native construct returned as pax, and the connector sits where it belongs as a pa reference. The decoder spells out grouping the source left implicit — summary &= ("- " & task & "\n") and if (count > 0) wear explicit parentheses — because it renders each expression with its structure shown rather than leaning on precedence. It reads a shade more carefully than what you wrote, and it compiles and runs identically.
Which you can check, because the decoded source is just pax. Run it through paxr and you get the same end state, count = 3 and the three-line summary; recompile it with paxc --target pa-legacy and you get the package back. The loop is closed: text to flow and back to text, no designer in the path.
That's the case for treating a flow as code, made concrete. You wrote a scheduled, looping, conditional flow as one file you can read top to bottom. You ran it locally before it touched the cloud, compiled it, imported it, and decoded it back. From here it's a file in a repository like any other — diff it, review it, branch it — and you let paxc and paxr handle the trip to Power Automate and back.