Skip to main content

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, or jq::<expr> for jq).
  • ->filter optionally transforms the resolved value; filters chain left to right.

Roots​

Request data (available on HTTP interfaces):

RootResolves to
bodythe request body — a|body::user.email|
paramsroute/query parameters — a|params::id|
headersrequest headers — a|headers::authorization|
ipthe resolved client IP
raw_bodythe exact request bytes
requestthe full request object

Config & environment:

RootResolves to
vara config-local variable from global.variables — a|var::api_base_url|
ap_vara platform managed variable, shared org-wide — a|ap_var::API_KEY|
secreta decrypted global.secrets value — a|secret::store::field|
envan OS environment variable (self-hosted only — unavailable in managed mode) — a|env::HOME|
templatea named response template
statedurable state — a|state::last_seen|, a|state::NAMESPACE.KEY|
uuida freshly generated UUID
timestamp:<spec>a formatted timestamp
includefile 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​

FilterDoes
upper / lowerchange case wholesale
snake_case / kebab_case / camel_case / pascal_casere-case an identifier — Order Ref ID → order_ref_id
title_case / sentence_casere-case prose
lengthcharacters 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_pathpull 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_uripercent-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_markdownHTML 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​

FilterDoes
to_numberparse 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.

FilterDoes
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_weekendSaturday 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_sinceepoch 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​

FilterDoes
uniquedrop duplicates, keeping first-seen order
sort / sort('field') / sort('field','desc')order an array; sort('desc') descends a list of scalars
reversereverse the order
first / lastone 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
compactdrop 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_arraythe 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​

FilterDoes
to_stringrender as text
single_quote / double_quotewrap the value in quotes. Not for SQL: pass SQL values as params
json_esc / json_escapeescape for embedding inside a JSON string
json_stringify (alias tojson)serialise as a JSON document
raw / literalkeep any a|...| in the value literal instead of resolving it
sometrue 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|
note

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|
note

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(...).