Skip to content

Transform

Reshape a value with JavaScript: build the output field by field, or write the expression yourself. Use it for split, join, sort and building one string out of a list: a CEL expression elsewhere in the document has none of them. It runs in the server, so it needs no agent. Here input is the run’s input. Read the node above by name, as anywhere else in the document: nodes.<id>.output.

  • Kind: transform
  • Palette: Activities
  • Run it on its own: yes
  • The step above returns a wide record and the next one wants three fields under other names.
  • Building one string out of a list, sorting, filtering or grouping: CEL expressions elsewhere in a graph have no join or sort, and this node’s JavaScript does.
  • Any reshaping that shouldn’t cost an agent: a transform runs in the engine.
Field Type Required Description
expr string no A JavaScript expression, instead of fields. In each mode i is the item and index its position; in all mode input is the whole run input. nodes.<id>.output, vars and run are in reach in both, so reshaping the node above is nodes.<id>.output rather than input.
fields array no The output, a field at a time: [{name, expr}] for a computed value or [{name, value}] for a fixed one. Either this or expr.
include_input boolean no Keep the incoming fields too, with yours layered over them. Off by default, so what the table lists is what comes out.
mode string no each (default) runs it per item; all runs it once over the whole input. One of "each", "all".

Not declared: what comes out depends on the configuration.

Starting points the inspector offers, each shown with the input it was written against.

Take a wide record and keep only what the next step needs, under the names it expects.

{
"fields": [
{
"expr": "i.order_id",
"name": "id"
},
{
"expr": "i.buyer.email",
"name": "customer"
},
{
"expr": "i.amount_cents / 100",
"name": "total"
}
],
"mode": "each"
}

Sample input:

[
{
"amount_cents": 2500,
"buyer": {
"email": "kim@example.com"
},
"internal_note": "ignore me",
"order_id": "A-1"
}
]

Leave the record alone and add something computed from it - a flag, a label, a total.

{
"fields": [
{
"expr": "i.amount > 100 ? 'large' : 'small'",
"name": "size"
}
],
"include_input": true,
"mode": "each"
}

Sample input:

[
{
"amount": 250,
"id": "A-1"
},
{
"amount": 40,
"id": "A-2"
}
]

One row per group, with a count and a sum. Needs the code mode: a per-field table describes one output row, and this makes fewer rows than it was given.

{
"expr": "Object.entries(Object.groupBy(input, r => r.team)).map(([team, rows]) => ({ team, count: rows.length, total: rows.reduce((s, r) => s + r.n, 0) }))",
"mode": "all"
}

Sample input:

[
{
"n": 2,
"team": "x"
},
{
"n": 3,
"team": "y"
},
{
"n": 1,
"team": "x"
}
]

Match each row against a second list and bring a field across - the shape you need when one step fetched the rows and another the thing they refer to.

{
"expr": "(() => { const by = Object.fromEntries(input.teams.map(t => [t.team, t.lead])); return input.items.map(i => ({ ...i, lead: by[i.team] })); })()",
"mode": "all"
}

Sample input:

{
"items": [
{
"name": "a",
"team": "x"
},
{
"name": "b",
"team": "y"
}
],
"teams": [
{
"lead": "kim",
"team": "x"
},
{
"lead": "sam",
"team": "y"
}
]
}

Turn comma-separated text into a clean, sorted list with no repeats.

{
"expr": "[...new Set(input.flatMap(r => r.tags.split(',').map(t => t.trim())))].sort()",
"mode": "all"
}

Sample input:

[
{
"tags": "urgent, billing"
},
{
"tags": "billing"
}
]

Drop the rest. Needs the code mode: a table describes one row, and this changes how many there are.

{
"expr": "input.filter(r => r.status === 'open')",
"mode": "all"
}

Sample input:

[
{
"id": "A-1",
"status": "open"
},
{
"id": "A-2",
"status": "closed"
}
]

Order by a field. b - a instead of a - b reverses it; for text use localeCompare.

{
"expr": "[...input].sort((a, b) => b.amount - a.amount)",
"mode": "all"
}

Sample input:

[
{
"amount": 40,
"id": "A-1"
},
{
"amount": 250,
"id": "A-2"
}
]

The top of the list, once it is in the order you want. Sort first if the order matters.

{
"expr": "input.slice(0, 10)",
"mode": "all"
}

Sample input:

[
{
"n": 1
},
{
"n": 2
},
{
"n": 3
}
]

One row per value of a field - the first one wins, so sort first if it matters which.

{
"expr": "input.filter((r, n) => input.findIndex(o => o.email === r.email) === n)",
"mode": "all"
}

Sample input:

[
{
"email": "kim@example.com",
"n": 1
},
{
"email": "sam@example.com",
"n": 2
},
{
"email": "kim@example.com",
"n": 3
}
]

Match each row against a second list on a shared key and copy a field over - the shape you need when one step fetched the rows and another fetched what they refer to.

{
"expr": "(() => { const by = new Map(input.customers.map(c => [c.id, c])); return input.orders.map(o => ({ ...o, customer_name: by.get(o.customer_id)?.name ?? null })); })()",
"mode": "all"
}

Sample input:

{
"customers": [
{
"id": 7,
"name": "Kim"
}
],
"orders": [
{
"customer_id": 7,
"id": "A-1"
},
{
"customer_id": 9,
"id": "A-2"
}
]
}

A record holding a list becomes one record per element, carrying the parent’s fields along.

{
"expr": "input.flatMap(r => r.items.map(item => ({ order: r.id, ...item })))",
"mode": "all"
}

Sample input:

[
{
"id": "A-1",
"items": [
{
"qty": 2,
"sku": "x"
},
{
"qty": 1,
"sku": "y"
}
]
}
]
version: hyperspool-graph/v1
nodes:
- id: fetch
type: http
config:
url: https://api.example.com/v1/orders
fail_on_error_status: true
- id: summary
type: transform
config:
mode: all
expr: |
nodes.fetch.output.body.orders
.filter(o => o.status === "open")
.map(o => `${o.id}: ${o.total}`)
.join("\n")
- id: post
type: shell
config:
run: { $template: "echo '{{ nodes.summary.output }}'" }
edges:
- { from: fetch, to: summary }
- { from: summary, to: post }

In all mode the expression runs once and its value is the output. To reshape the node above, name it (nodes.fetch.output); input is the run’s input. The canvas shows what the transform returns as you type, against a pinned sample. See Reshaping data.

See the node reference for every other node.