Interpolation reference
Air Pipe interpolates values into your config at runtime using the a|…| marker.
This page is the full grammar reference — for a gentle introduction see
Variable substitutions.
Every marker has the form:
a|<root>::<path>->filter1->filter2|
<root>selects a data source (see below).::<path>optionally drills into it (dotted path, orjq::<expr>for jq).->filteroptionally transforms the resolved value; filters chain left to right.
Roots
Request data (available on HTTP interfaces):
| Root | Resolves to |
|---|---|
body | the request body — a|body::user.email| |
params | route/query parameters — a|params::id| |
headers | request headers — a|headers::authorization| |
ip | the resolved client IP |
raw_body | the exact request bytes |
request | the full request object |
Config & environment:
| Root | Resolves to |
|---|---|
var | a config-local variable from global.variables — a|var::api_base_url| |
ap_var | a platform managed variable, shared org-wide — a|ap_var::API_KEY| |
secret | a decrypted global.secrets value — a|secret::store::field| |
env | an OS environment variable (self-hosted only — unavailable in managed mode) — a|env::HOME| |
template | a named response template |
state | durable state — a|state::last_seen|, a|state::NAMESPACE.KEY| |
uuid | a freshly generated UUID |
timestamp:<spec> | a formatted timestamp |
include | file include (self-hosted) |
Action outputs: any root that is not a built-in is treated as an action
name — a|FetchUser::email| reads the email field from the FetchUser
action's output. Use jq:: for richer extraction: a|FetchUser::jq::.items[0].id|.
A path the program does not find is null, as in jq. A program that does not parse, or
fails on the data it gets (.v.x where v is a number), fails the step with the jq error,
and validation rejects one that does not parse. Before 3.20.0 both rendered null.
Filters
Chain with ->. A filter takes the value on its left and hands the next one its
result, so they read left to right.
# Uppercase a param, defaulting when absent
url: "https://api.example.com/a|params::region->upper->default(US)|"
A value that isn't there stays missing through the text filters, so a default(...)
after them supplies the fallback: with no region param that URL ends in /US. With no
default, a marker that is a field's whole value gives JSON null (SQL NULL as a
param), and inside a longer string it reads null. length gives 0; the number, date
and array filters fail the step. Before 3.20.0 the text filters worked on the word null
instead: ->upper gave NULL, ->hash the same digest for every missing value, and the
default above never fired.
Text
| Filter | Does |
|---|---|
upper / lower | change case wholesale |
snake_case / kebab_case / camel_case / pascal_case | re-case an identifier — Order Ref ID → order_ref_id |
title_case / sentence_case | re-case prose |
length | characters in a string, elements in an array, keys in an object |
prefix('x') / suffix('x') / wrap('x') | add text around the value |
hash / hash('sha256') / hash('sha256','base64') | unkeyed digest — sha1, sha256 (default), sha384, sha512; encoded hex (default), base64 or base64url. For a signature, use the hmac transform instead: a digest proves nothing about origin |
extract_email / extract_domain / extract_url / extract_url_path | pull the first one out of free text; null when there is none, so pair with default(...) |
encode_uri_component (alias url_encode) | percent-encode a value going into a URL as ONE query value or path segment — : / and spaces escaped, the unreserved - _ . ~ left alone so a provider comparing a redirect_uri literally still matches |
encode_uri | percent-encode a WHOLE URL, leaving : / ? # & = intact so it stays a URL |
decode_uri / decode_uri_component (alias url_decode) | the inverse of each |
strip_tags / strip_markdown | HTML or Markdown reduced to the prose inside it. strip_tags parses the page the way a browser does, so <script> bodies and attribute text never leak into the result and block elements keep their words apart. For pulling out a specific element instead, use the extract_with_css transform |
Numbers
| Filter | Does |
|---|---|
to_number | parse the value as a number |
number_format(2) | fixed decimals, grouped thousands — 1,234,567.89 |
number_format(2,'.',',') | the same with the separators swapped — 1.234.567,89 |
Dates
Every date filter accepts RFC 3339, RFC 2822, YYYY-MM-DD[ HH:MM:SS], or an epoch
number (10 digits seconds, 13 milliseconds), and emits RFC 3339 UTC — so they chain.
| Filter | Does |
|---|---|
date_format('%Y-%m-%d') | strftime rendering; a second argument names an IANA timezone |
date_tz('Australia/Sydney') | the same instant, written in another zone |
date_add('7d') / date_sub('12h') | shift by 90s, 15m, 12h, 7d, 2w |
start_of('day') / end_of('month') | truncate to second, minute, hour, day, week (Monday), month, quarter, year. A second argument reads the calendar in a timezone — the start of the Sydney day is not the start of the UTC one |
date_part('month') | one field as a number: year, quarter, month, day, hour, minute, second, millisecond, weekday (Monday = 1), week (ISO), day_of_year |
is_weekend | Saturday or Sunday, in the given timezone |
since('hours') / until('days') | whole units elapsed since, or remaining until, the value — milliseconds, seconds, minutes, hours, days, weeks |
epoch / days_since | epoch seconds; whole days old |
# Everything since the start of the month, in the customer's timezone
query: |
SELECT * FROM orders
WHERE created_at >= 'a|timestamp:s->start_of('month','Australia/Sydney')|'
Arrays and objects
| Filter | Does |
|---|---|
unique | drop duplicates, keeping first-seen order |
sort / sort('field') / sort('field','desc') | order an array; sort('desc') descends a list of scalars |
reverse | reverse the order |
first / last | one element, or null when the array is empty |
chunk(100) | split into runs of N — batching an API that takes 100 ids at a time |
compact | drop null and "" entries from an array or object. 0 and false are kept: they are values somebody sent |
join(', ') | join into a string |
array_values / json_array | the elements without brackets / the array as JSON |
map(...) | template each element ($item, $index) |
access('key') / path(...) | read into an object |
An array filter pointed at something that is not an array fails the action rather than passing the value through, so the mistake surfaces at the marker instead of in the output.
Rendering
| Filter | Does |
|---|---|
to_string | render as text |
single_quote / double_quote | wrap the value in quotes. Not for SQL: pass SQL values as params |
json_esc / json_escape | escape for embedding inside a JSON string |
json_stringify (alias tojson) | serialise as a JSON document |
raw / literal | keep any a|...| in the value literal instead of resolving it |
some | true when the value is present and non-empty |
default(...) | substitute when the value is null or empty |
Function wrappers
Wrap a marker to coerce its rendering: single_quote(...), double_quote(...),
json_esc(...), json_stringify(...). These are useful when injecting values
into JSON bodies and other text. A wrapper applies wherever the marker appears — on its
own, or inside a longer string such as an HTTP body field.
json_stringify follows JSON.stringify semantics: an object or array is
emitted as its compact JSON document, anything else as a JSON string literal.
The typical use is handing a structured result to an API that wants it as a
string — an LLM tool message, say:
- role: tool
tool_call_id: a|Plan::callId|
content: json_stringify(a|Tool|) # or: a|Tool->json_stringify|
A marker that is a field's whole value normally keeps its type — content: a|Tool|
sends an object. The text filters (json_stringify/tojson, json_escape,
to_string) are the exception: they exist to produce text, so their result stays
a string rather than being re-read as JSON.
Values in SQL
Don't put markers in a database action's query text. Write a placeholder ($1, $2, …
on Postgres) and list the values under params:, in order. The database receives them as
values, never as SQL, so a name like Mary O'Brien is stored as written and nothing a
caller sends can change the statement:
- name: Save
database: main
query: |-
INSERT INTO users (login, name, repo_count)
VALUES ($1::text, $2::text, $3::int)
RETURNING login, name, repo_count
params:
- a|body::login|
- a|body::name->default('')|
- a|body::repo_count->default(0)|
Filters work inside params as anywhere else. Give each placeholder a cast ($1::text,
$3::int) so Postgres knows its type. A query-string value is always text, and text binds
to an ::int placeholder only once a filter such as ->default(25) has made it a number;
LIMIT $1::text::int takes text and numbers alike. Either way a value that isn't a number
(?limit=two) fails the action instead of reaching the statement.
Quoting filters are not a substitute. With the value pasted into the text,
VALUES (a|body::name->single_quote|) fails on Mary O'Brien with
42601: syntax error at or near "Brien", and an unquoted number such as
LIMIT a|params::limit| lets ?limit=1 OFFSET 2 add SQL to the statement.
Pipes inside a marker
A marker ends at its first |, so a jq expression written inside one needs to say which
pipes belong to it. Two rules do that:
- a
|inside(...),[...]or{...}is part of the expression; \|escapes a top-level pipe.
params:
# bracketed — the pipes are inside [ ... ]
- 'a|Process::jq::[.failed[] | {id: .item.id, reason: .error}]|'
# top-level pipe, escaped
- 'a|Rows::jq::.[0] \| .id|'
Quotes are deliberately not tracked, so an apostrophe (->default(can't)) never swallows
the closing pipe. A marker also still ends at a newline: keep the expression on one line, or
put it in a post_transforms step where pipes need no escaping at all.
Multi-line values
For templates, HTML or JSON, use a YAML literal block so the marker expands inside the string:
http:
url: https://example.com/webhook
body: |
a|var::some_big_json|
Always close the marker with a trailing | — input: a|body|, not input: a|body.
The open form without the trailing pipe is deprecated; older examples may still
show it. The legacy <OR> fallback syntax is likewise deprecated — use ->default(...).