writing-handlebars — Claude Code skill: what it does, how to install
Guides coding agents through writing Handlebars expressions for Celigo integrations across HTTP bodies, SQL queries, mappings, and filters.
npx skills add celigo/ai --skill writing-handlebars -a claude-codeWhat writing-handlebars does
This skill instructs the agent on Celigo Handlebars rules under the AFE 2.0 runtime standard. It enforces the mandatory record. field prefix for current record data, avoiding obsolete data.field notation. The skill tells the agent to extract paths directly from sampleData or mockOutput. A wrong path renders empty without raising an error.
It details output escaping rules for double {{ }} and triple {{{ }}} braces. Double braces handle URL-encoding in relative URIs and JSON-escaping inside quoted strings, while wrapping SQL string literals in single quotes. Triple braces insert raw output for numbers, booleans, and serialized JSON. Using triple braces on a string inside a JSON payload breaks the body with a 422 invalid_json response.
The skill covers delta tokens like {{lastExportDateTime}}, file metadata objects, and 80 custom helpers. Celigo has no date helper. Using date instead of timestamp fails immediately.
What it helps with
- Build JSON HTTP request bodies with proper string escaping
- Write RDBMS SQL queries using
record.prefixes and escaped literals - Format dynamic export filenames using timestamps and file metadata
- Construct filter rule operands using single Handlebars expressions
Example prompts for the agent
- Generate a JSON request body for an HTTP import using
sampleData - Write an SQL merge query matching
record.emailand comparingrecord.qty - Create an S3 dynamic export filename with an America/New_York timestamp
How to install writing-handlebars
1Skills CLI (Vercel)
npx skills add celigo/ai --skill writing-handlebars -a claude-code2Manually: put the files in place
~/.claude/skills/writing-handlebars/SKILL.mdCopy the whole skill folder; for one project use .claude/skills/writing-handlebars/
1Skills CLI (Vercel)
npx skills add celigo/ai --skill writing-handlebars -a codex2Manually: put the files in place
.agents/skills/writing-handlebars/SKILL.mdFor all projects use ~/.agents/skills/
1Skills CLI (Vercel)
npx skills add celigo/ai --skill writing-handlebars -a cursor2Manually: put the files in place
.cursor/skills/writing-handlebars/SKILL.mdCursor also reads .agents/skills/ and .claude/skills/
1GitHub CLI
gh skill install celigo/ai writing-handlebars2Manually: put the files in place
.github/skills/writing-handlebars/SKILL.md1Skills CLI (Vercel)
npx skills add celigo/ai --skill writing-handlebars -a gemini-cli2Platform CLI
gemini skills install https://github.com/celigo/ai --path skills/writing-handlebarsManually: put the files in place
.devin/skills/writing-handlebars/SKILL.mdThe legacy .windsurf/skills/ path still works
Skills CLI (Vercel)
npx skills add celigo/ai --skill writing-handlebars -a opencodeClaude.ai
Zip the folder and upload it in Settings → Capabilities → Skills
Step by step, with commands for each platform: How to install skills in Claude Code: five ways
Questions about writing-handlebars
How do I install writing-handlebars in Claude Code?
Run npx skills add celigo/ai --skill writing-handlebars -a claude-code. You can also place the file directly at ~/.claude/skills/writing-handlebars/SKILL.md.
Why does Celigo require the record. prefix in Handlebars?
Under AFE 2.0, record field access requires the record. prefix across mappings, SQL queries, and templates. Bare field names and older data.field paths are deprecated.
When should triple braces {{{ }}} be used instead of double braces {{ }}?
Double braces escape output for JSON strings, URL parameters, and SQL literals. Triple braces output raw values, required for numbers, booleans, and pre-serialized JSON.
Source text
SKILL.md · MIT
---
name: writing-handlebars
description: Write Handlebars template expressions for Celigo integrations -- dynamic values in mappings, HTTP bodies, SQL queries, URIs, and filters. Use when building any resource configuration that needs computed, conditional, or formatted field values.
---
<!-- TIER:1 -->
# Writing Handlebars Expressions
Handlebars is Celigo's template language for embedding dynamic values into resource configurations. Any string field that the platform evaluates at runtime can contain Handlebars expressions.
Concerns when writing Handlebars:
- **Context** -- where the expression runs determines what data is available and how output is treated
- **Braces** -- double `{{ }}` vs triple `{{{ }}}` controls output escaping
- **Field access** -- `record.` prefix (AFE 2.0) outside a few bare-field contexts ([Key Syntax](#key-syntax)), `@root` for job/settings/connection, bracket notation for special characters
- **Helpers** -- 80 custom helpers for math, string manipulation, dates, encoding, regex, lookup caches, and more
- **Block helpers** -- `#each`, `#if`, `#compare`, `#with` for iteration and conditional logic
- **Date/time** -- moment.js format tokens with timezone support
Used across exports, imports, mappings, output filters, and APIs.
**Copy field paths from the data, never from the request.** Before writing an expression, read a record the step actually receives -- the import's `sampleData`, the source export's `mockOutput` or a recent run's records, or a failing record's retry data -- and take each path from it. The field the user names, or the destination field being filled, isn't evidence of the source's name (`phone` may arrive as `phoneNumber`), and a wrong path renders empty with no error ([step 2](#2-know-the-data-shape)). An import holds a record of its own only in `sampleData`; when it has none, read the flow that uses it (the import's dependencies name it) and that flow's source export's `mockOutput` or a recent run's records. When you report the change, say which record each path came from.
## Where Handlebars Are Used
### Mapping extracts
In import `mappings[].extract` fields, Handlebars concatenates, transforms, or conditionally selects values. The context is the current record.
### HTTP request templates
Export and import `http` blocks use Handlebars in `relativeURI`, `body`, `headers`, and `postBody`. In a `relativeURI`, double braces URL-encode the value at runtime -- use them for record values in path segments and query parameters, and triple braces only for a value that is already URL-encoded (a paging cursor or `next` link from a response). In a JSON body, double braces are JSON-escaped at runtime, so write string values as `"{{record.name}}"`; triple braces insert raw text, and a value holding a quote, backslash, newline or tab then breaks the body (422 `invalid_json`). Use triple braces for numbers, booleans and serialized JSON in a body, and in headers.
### RDBMS SQL queries
SQL queries in `rdbms.query` use Handlebars with the mandatory `record.` prefix (a `per_page` query iterates `batch_of_records` instead -- see [Template roots](#template-roots)). Double braces render the value as a single-quoted SQL string literal with embedded quotes escaped -- use them for strings and timestamps, without adding quotes of your own. Triple braces output the raw value, for numbers and identifiers. For full SQL patterns (MERGE, upsert, bulk operations, dialect differences), see [writing-sql](../writing-sql/SKILL.md).
### Filter rule operands
Filters are rule arrays, not templates (see [configuring-filters](../configuring-filters/SKILL.md)). A Handlebars string can be one operand of a rule, but only when the whole string is a single `{{…}}` expression with `record.`-prefixed paths: `["equals", "{{lowercase record.status}}", "open"]`. It renders as text, so wrap it in `["number", …]` before comparing it to a number.
### File paths and names
Dynamic file names in FTP/S3 exports and imports use Handlebars for timestamps and record-derived values. A file-provider export or lookup also describes each record's source file as `fileMeta`, with exactly `fileName`, `fileSize` and `lastModifiedTime` (epoch milliseconds): `{{fileMeta.fileName}}`, `{{dateFormat "YYYY-MM-DD" fileMeta.lastModifiedTime "x"}}`. A run-date stamp is `{{timestamp "YYYYMMDD" "<time zone>"}}`: `orders_{{timestamp "YYYYMMDD" "America/New_York"}}.csv` renders `orders_20261023.csv`. There is no `date` helper (`{{date 'YYYYMMDD'}}` is refused with `Missing helper: "date"`), and a bare `{{timestamp}}` renders the full ISO time, colons included. The names are case-sensitive ([configuring-exports > File Transfers](../configuring-exports/SKILL.md#file-transfers)).
### Delta tokens
Delta exports get two platform variables: `{{lastExportDateTime}}`, the last successful run (the window's lower bound), and `{{currentExportDateTime}}`, this run's upper bound. These are not record fields -- the platform injects them at runtime into the export's HTTP/query context only.
## Quick Reference
### Context Decision Matrix (AFE 2.0)
All contexts use `record.` prefix to access the current record's fields (AFE 2.0). Do NOT use bare field names, `data.field`, or `data.0.field` -- those are deprecated AFE 1.0 patterns. **Exceptions:** Mapper 1.0 (Salesforce / NetSuite DA), NetSuite saved-search criteria and an HTTP import lookup's body use bare field names without `record.` prefix (see [Key Syntax](#key-syntax)).
| Where | Syntax | Data prefix | Example |
|---|---|---|---|
| Mapping extract | `{{ }}` (double) | `record.` | `{{record.firstName}}` |
| HTTP relative URI | `{{ }}` (double) -- URL-encoded at runtime; triple only for an already-encoded value | `record.` | `/customers?email={{record.email}}` |
| HTTP body (JSON) | `{{ }}` inside quotes for strings (JSON-escaped); `{{{ }}}` for numbers, booleans and serialized JSON | `record.` | `{"name": "{{record.name}}", "qty": {{{record.qty}}} }` |
| HTTP import lookup body (`http.lookups[].body`) | as in a JSON body | none: the record's fields are at the top level | `{"email": "{{email}}"}` |
| SQL query (RDBMS) | `{{ }}` for strings and dates (quoted for you), `{{{ }}}` for numbers and identifiers | `record.` | `WHERE email = {{record.email}} AND qty > {{{record.qty}}}` |
| Filter rule operand | `{{ }}` (double), one expression filling the operand | `record.` | `["equals", "{{record.status}}", "open"]` |
| Delta URI parameter | `{{ }}` (double) | (platform-injected) | `?updated_since={{lastExportDateTime}}` |
An ID with no special characters renders the same with either brace style, which is why URI examples like `/customers/{{{record.id}}}` work too.
Additional context objects available via `@root`:
| Object | Description |
|--------|-------------|
| `record` | Current record being processed (`rows` when it's a grouped record) |
| `job` | Current job metadata |
| `settings` | Settings by scope key: `settings.integration`, `settings.flowGrouping`, `settings.flow`, `settings.connection`, `settings.iClient`, and the step's own `settings.export` or `settings.import` |
| `connection` | Connection object (for auth headers) |
**The root follows the record's shape.** An object record is `record`; a grouped record (an array of rows) is `rows`, and `record.*` is undefined on it. `batch_of_records` replaces both only when an HTTP import sends several records per request (`http.batchSize` above 1) or an RDBMS import runs `per_page`. One-to-many doesn't batch by itself: each child renders as `record`. See [Template roots](#template-roots).
### Key Syntax
- **`{{double-braces}}`** -- context-aware output at runtime. In a `relativeURI` the value is URL-encoded, which a path segment or query parameter needs. In an RDBMS query it becomes a single-quoted SQL string literal with embedded quotes doubled (`O'Neil` renders `'O''Neil'`), so never add your own quotes around it. In a JSON body it is JSON-escaped, so it goes inside the value's quotes (`"{{record.name}}"`).
- **`{{{triple-braces}}}`** -- raw output, no encoding or quoting. Use for serialized JSON, numbers and booleans in a body, headers, file paths, numbers and identifiers in SQL, and URI values that are already URL-encoded (double braces would encode them a second time). A raw string inside a JSON body's quotes breaks the body on the first quote, backslash, newline or tab. Never hand-quote a triple-brace string in SQL (`'{{{record.name}}}'`) -- the first apostrophe in the data breaks the statement.
- **The preview isn't the runtime.** The expression preview HTML-escapes double braces (`&` shows as `&`, `'` as `'`); a running request URL-encodes them in a URI, quotes them in SQL and JSON-escapes them in a JSON body instead, with no HTML escaping. An `'` in the preview is no reason to switch a body value to triple braces. It also renders a null value empty where a run writes `null` (gotcha 16).
- **Always use `record.` prefix (AFE 2.0)** -- `{{{record.fieldName}}}` in all contexts, never bare `{{{fieldName}}}` or `{{{data.fieldName}}}` (AFE 1.0). Nested fields: `{{{record.properties.email}}}`.
- **Exceptions: Mapper 1.0, NetSuite search criteria and HTTP import lookup bodies** -- Mapper 1.0 (Salesforce / NetSuite DA) uses bare field names without `record.` prefix. So do NetSuite saved-search lookup criteria (`netsuite.restlet.criteria[].searchValue` and criteria formulas), where the incoming record is the root: write `{{poNumber}}`. `{{record.poNumber}}` resolves in runs but fails the step preview with `handlebars_template_parse_error`. So does the request template of an HTTP import's own lookup, in `http.lookups[].body` (a `postBody` there is never sent): the record's fields sit at the top level, so `{{email}}` renders the record's value and `{{record.email}}` renders empty ([configuring-imports > Gotchas](../configuring-imports/SKILL.md#gotchas)). A lookup export (`isLookup: true`) reads `{{record.email}}` as usual.
## Related Skills
- [configuring-exports > Quick Reference](../configuring-exports/SKILL.md#quick-reference) -- export adaptor types, delta sync setup, output filters
- [configuring-imports > Quick Reference](../configuring-imports/SKILL.md#quick-reference) -- import adaptor types, operation modes, mapping systems
- [writing-mappings > Quick Reference](../writing-mappings/SKILL.md#quick-reference) -- Mapper 2.0 fields, lookups, conditional mappings
<!-- TIER:2 -->
## Syntax Fundamentals
### Braces
| Syntax | Behavior | When to use |
|--------|----------|-------------|
| `{{ }}` | Context-aware at runtime -- URL-encoded in a `relativeURI`; a quoted SQL string literal (embedded quotes doubled) in an RDBMS query; JSON-escaped in a JSON body | Record values in URIs; string and date values in SQL, with no quotes of your own; string values inside quotes in a JSON body |
| `{{{ }}}` | Raw output, no encoding or quoting | Serialized JSON, numbers and booleans in a body; headers, file paths, SQL numbers and identifiers, and URI values that are already URL-encoded |
| `\{{ }}` | Escaped tag -- a backslash before `{{` prints that tag literally (`\{{name}}` renders `{{name}}`) | Emitting literal Handlebars syntax. Raw blocks (`{{{{raw}}}}`) aren't supported -- they fail at runtime |
Inside a GraphQL `variables` value stored as a JSON string, the body is two JSON levels deep and the brace choice shifts with it: a string value is `"{{jsonEncode record.x}}"` and an object or array `{{jsonSerialize record.x}}`, both in double braces; triple braces there fail with 422 `invalid_json` ([configuring-imports > GraphQL imports](../configuring-imports/SKILL.md#graphql-imports)).
### Field access
| Pattern | Meaning |
|---------|---------|
| `record.fieldName` | Standard field reference (AFE 2.0), outside the [Key Syntax](#key-syntax) bare-field contexts |
| `record.nested.field` | Dot-notation for nested objects |
| `record.[Field With Spaces]` | Bracket notation for special characters in field names |
| `record.items.[0].name` | Array index access. Mid-path, `record.items.0.name` works too; a trailing `.0` (`record.tags.0`) and `record.items[0]` fail to parse |
| `@root.fieldName` | Root context -- escape nested `#each` scope |
| `../fieldName` | Parent context -- one level up from current `#each` |
| `this` | Current iteration element |
| `@index` / `@key` | Current array index / object key in `#each` |
| `@first` / `@last` | Boolean -- first/last element in `#each` iteration, over an array or an object |
| `as \|value key\|` | Block parameters: `{{#each record.address as \|value key\|}}` names the element and its key (the index, for an array); the names stay visible inside nested loops |
### Subexpressions (nesting helpers)
Use `()` to nest one helper's output as input to another. The inner helper evaluates first:
```
{{uppercase (split record.fullName " " 0)}} -- split then uppercase the first word
{{{base64Encode (join ":" record.user record.pass)}}} -- join then encode
{{#compare (add record.qty 1) ">" 100}}...{{/compare}} -- add then compare
{{#each (after record.tags 3)}}...{{/each}} -- slice then iterate
```
Subexpressions can be nested multiple levels deep. Each `()` resolves inside-out.
### Block helpers
- `{{#each record.items}}...{{/each}}` -- iterate array or object
- `{{#if record.active}}...{{else}}...{{/if}}` -- conditional
- `{{#compare val1 "==" val2}}...{{/compare}}` -- comparison (`==`, `===`, `!=`, `!==`, `<`, `>`, `<=`, `>=`). It compares like JavaScript: two strings compare as text, and a number on either side makes it numeric, so write a numeric threshold unquoted: `{{#compare record.total_price ">" 500}}` ([gotcha 4](#gotchas))
- `{{#with record.address}}...{{/with}}` -- change context scope
### Date/time formatting
Uses moment.js tokens. Pick braces by the field the date lands in, as for any value (see [Braces](#braces)): double in a URI, a SQL query or a quoted JSON string.
Common tokens: `YYYY` (4-digit year), `MM` (2-digit month), `DD` (2-digit day), `HH` (24h hour), `mm` (minute), `ss` (second), `SSS` (millisecond), `Z` (timezone offset), `X` (Unix seconds), `x` (Unix milliseconds). As an input format, `x` and `X` read an epoch: `{{dateFormat "YYYY-MM-DD" record.updatedMs "x"}}`. An epoch held in a string renders `Invalid date` without one; a millisecond epoch held as a number parses either way.
Timezone: pass as the **fourth** argument, after the input format (`""` when the input is ISO 8601) -- `{{{dateFormat "YYYY-MM-DD" record.date "" "US/Eastern"}}}`. The signature is `dateFormat outputFormat date inputFormat timezone`; a zone in the third slot is read as the input format, and the output is the literal text `Invalid date`. Name an IANA zone (`America/Chicago`, `US/Central`): an abbreviation such as `CST` is refused with 422 `doesn't support timezone=CST`. Without a zone the output is UTC; an input that carries its own offset converts correctly. Tokens are case-sensitive: `MM` is the month and `mm` the minute, so a 24-hour time of day is `HH:mm:ss` (`HH:MM:SS` renders hour, month and a fraction of a second). `timestamp` (the current time) takes the zone second; pass one rather than rely on a default: `{{timestamp "YYYY-MM-DDTHH:mm:ssZ" "America/Chicago"}}`.
### Date arithmetic
`dateAdd` adds **milliseconds** by default (1 hour = 3,600,000; 1 day = 86,400,000), or a count of a unit you name: `{{dateAdd record.d 3 "d"}}`, `{{dateAdd record.d -2 "h" "America/New_York"}}`. Units are `ms`, `s`, `min`, `h`, `d`, `w`, `M` and `Y` (or long forms such as `days`), quoted. A third argument that isn't a unit is read as the time zone, so bare `m` and `y` fail with 422 `doesn't support timezone=m`. With a zone, `d`, `w`, `M` and `Y` follow that zone's calendar -- the same wall-clock time across a daylight-saving change -- while `ms` through `h` add exact durations; without one the math runs in UTC. Month and year math clamps (Jan 31 + 1 `M` is Feb 28), and a result with a zone has no milliseconds (`2026-02-01T10:00:00Z`).
## Runtime Context at Each Stage
What `{{record.X}}` or `{{settings.Y}}` actually resolves to depends on **which bubble** the expression runs in and on the shape of the record it renders against. The bubble shapes below come from captures of the whole context on import and lookup bubbles, echoed through integrator.io's own `/v1/mirror`; such a capture carries credential material, and the mirror's reply echoes the connection's `Authorization` header, so read [How to rediscover the shape for any bubble](#how-to-rediscover-the-shape-for-any-bubble) before making one. A capture lists the context's own keys, so it never shows `record` or `rows` (see [Template roots](#template-roots)).
### Template roots
- **`record`** -- the record, when it's an object.
- **`rows`** -- the record, when it's grouped (an array of rows: a `groupByFields` export, file key columns, a grouped NetSuite saved search). Read it as `{{rows.0.field}}` or `{{#each rows}}`; `record.*` is undefined on it.
- **`batch_of_records`** -- the root only when an HTTP import sends several records per request (`http.batchSize` above 1) or an RDBMS import runs `per_page`. Iterate `{{#each batch_of_records}}` and read `this.record.<field>` (`this.rows` for a grouped record); a root-level `record` or `rows` is undefined there.
- **One-to-many doesn't batch by itself** -- each child renders as `record` (children are batched only under the `batchSize` rule above), and with `pathToMany` set the parent's other fields are under `record._PARENT`.
- **`data`** -- always lists the records in the current request: one record when the import sends one per request.
### Import bubble (HTTPImport, NetSuiteDistributedImport, etc.)
On an HTTP import, fields render against different versions of the record:
- `http.body` renders against the mapped record (unless `http.sendPostMappedData` is false), so template it with the mapped (destination) field names; a mapping that outputs the `rows [ ]` structure makes the body's root `rows`. Leave `http.body` unset to send the mapped record as is.
- `relativeURI`, header values and `mappings[].extract` see the record before mapping (source field names).
- An undefined path renders empty in a body and the record still succeeds (`strictHandlebarEvaluation` doesn't change that). In a relative URI or a header it fails the request. A null value is different: it renders as the text `null` (gotcha 16).
Besides the root, the context carries:
```
{
"data": [ ...the records in this request... ],
"lookup": { ...merged results from preceding lookup steps... },
"recordLookupError": null | { ... }, // set when a lookup failed
"settings": { "import": {...}, "connection": {...}, ... }, // one key per scope
"connection": { /* FULL connection object: auth, baseURI, encrypted credential fields, etc. */ },
"import": { /* full import config */ },
"job": { "parentJob": { "_id", "type", "startedAt", ... } },
"templateVersion": 1,
"testMode": false
}
```
### Lookup bubble (HTTPExport with `isLookup: true`)
Lookup request templates run per-record with a **different** shape (an HTTP import's own `http.lookups[]` aren't lookup exports: `record.` renders empty in their body, so name the fields at the top level; see [Key Syntax](#key-syntax)):
```
{
"exportStartTime": "ISO timestamp",
"settings": { "export": {...}, "connection": {...} },
"connection": { /* full connection object, encrypted credential fields included */ },
// the record is spread at TOP LEVEL
"_id": "...",
"name": "...",
"<other record fields>": "...",
"data": {
/* copy of the record */,
"_INITDATA": { /* original record before transforms */ }
}
}
```
### Export bubble (source generator)
Export URI templates and delta tokens have a minimal context — the platform injects `{{lastExportDateTime}}`, `{{currentExportDateTime}}`, plus `settings` and `connection`. No `record.` context exists yet (records haven't been fetched).
### Key differences between import and lookup contexts
| Context key | Import | Lookup |
|---|---|---|
| Address the record as | `record.<field>` (`rows` when grouped, `batch_of_records` when batched) | `record.<field>` (the capture also shows its fields at the top level and in `data`) |
| `data` shape | the records in this request (one when the import sends one per request) | single record (with `_INITDATA` nested) |
| `exportStartTime` | No | Yes |
| `lookup` | Yes (merged preceding results) | N/A |
| `import` / `job` / `recordLookupError` / `testMode` | Yes | No |
| `connection` (full) | Yes | Yes |
### How to rediscover the shape for any bubble
To see only what a template renders, skip the mirror: the Handlebars processor ([step 5](#5-test-the-expression)) renders it against a record you supply and calls nothing. To see what the runtime binds, serialize only what you need. `{{{jsonSerialize this}}}` sends the whole runtime context to whatever endpoint the step calls, and that context includes the step's connection with its encrypted credential fields. To see a record's shape, set the body on an HTTP import or lookup to `{{{jsonSerialize record}}}` (`rows` for a grouped record) and point it only at integrator.io's own `/v1/mirror`, never a third-party echo service. Enable flow execution logging, run the flow, and inspect the `apiCall.response.body` — it's a copy of what you sent. Read only that body: the mirror also echoes the request's headers, `Authorization` included, as response headers. Execution logs mask it, but runtime `_headers` -- an `imports invoke` result, a response mapping's input, a postSubmit hook's `responseData` -- carry the calling connection's token in clear. Never print an invoke or preview result's `_headers`, never response-map `_headers` from a mirror call, and never log them from a hook. Capture the whole context only when you need a key outside the record, still only to `/v1/mirror`, and treat the result as a secret: it holds that credential material. A whole-context capture lists the context's own keys, and `record` and `rows` are aliases, not keys, so it never prints them: take the root from the record's shape ([Template roots](#template-roots)), not from the capture. This works for any bubble whose adaptor sends an HTTP body.
## How to Write a Handlebars Expression
### 1. Identify the context
Where the expression runs determines what data is available. In AFE 2.0, contexts use `record.` to access the current record, apart from the [Key Syntax](#key-syntax) exceptions:
| Context | Available data | Prefix |
|---------|---------------|--------|
| Mapping extract | Current record | `record.` |
| HTTP body/URI | Current record | `record.` |
| HTTP import lookup body | Current record, fields at the top level | none (`{{email}}`) |
| RDBMS query | Current record | `record.` |
| Filter rule operand | Current record | `record.` |
| Delta URI parameter | Platform variables | `lastExportDateTime`, `currentExportDateTime` |
Other context objects (`job`, `settings`, `connection`) are accessible via `@root` -- e.g., `{{@root.connection.http.encrypted.apiKey}}`.
Reference settings through a scope key: `{{settings.integration.x}}`, `{{settings.flowGrouping.x}}`, `{{settings.flow.x}}`, `{{settings.connection.x}}`, `{{settings.iClient.x}}`, or the step's own `{{settings.export.x}}` / `{{settings.import.x}}`. Steps inside an API or a Tool get only their own scope plus connection and iClient. `{{settings.x}}` and paths built from settings-form labels resolve to nothing: they render empty in a body and fail the request in a relative URI or header, where rendering is strict.
When the step batches (`http.batchSize` above 1, or an RDBMS `per_page` query), iterate `{{#each batch_of_records}}{{this.record.field}}{{/each}}`. A grouped record is `rows` (`{{rows.0.field}}`), and a one-to-many child is `record`, with its parent's fields under `record._PARENT` when `pathToMany` is set.
### 2. Know the data shape
Before writing any expression, inspect what the input data looks like. For an import, that's the record entering it: the import's `sampleData` when it has one, otherwise the records of the flow's source export (its `mockOutput`, or a recent run). The name of the field you're filling isn't evidence of the record's (`email` may arrive as `emailAddress`), and a wrong path renders empty in a body with no error.
```bash
# Test-run an export to see actual record shapes
celigo exports invoke <exportId>
# Check mock output for the expected shape
celigo --jq '.mockOutput' exports get <exportId>
```
### 3. Choose the right braces
- `{{ }}` (double) for record values in a `relativeURI` (URL-encoded at runtime), for string and date values in SQL (quoted and escaped for you), and for string values inside quotes in a JSON body (JSON-escaped)
- `{{{ }}}` (triple) for serialized JSON, numbers and booleans in a body, headers, file paths, numbers and identifiers in SQL, and URI values that are already URL-encoded
- In mapping extracts and display text, `{{ }}` where HTML escaping is acceptable. Free text in a JSON body -- a message, a note, a description -- is a string value like any other: `"{{record.note}}"`
- Judge a double-brace URI, SQL or JSON-body value by a run, not the preview -- the preview HTML-escapes double braces, which the runtime doesn't
### 4. Find the right helper
See the [helper index](references/helpers/helper-index.md) for all 80 custom helpers. Key categories:
- **[Math](references/helpers/math.md)** -- `abs`, `add`, `subtract`, `multiply`, `divide`, `modulo`, `ceil`, `floor`, `round`, `sum`, `avg`, `random`, `toFixed`, `toExponential`, `toPrecision`
- **[String](references/helpers/string.md)** -- `uppercase`, `lowercase`, `capitalize`, `capitalizeAll`, `camelcase`, `pascalcase`, `snakecase`, `dashcase`, `dotcase`, `pathcase`, `sentence`, `trim`, `trimLeft`, `trimRight`, `padLeft`, `padRight`, `replace`, `replacefirst`, `removefirst`, `chop`, `truncateWords`, `sanitize`, `split`, `join`, `reverse`, `occurrences`, `substring`
- **[Array](references/helpers/array.md)** -- `after`, `before`, `sort`, `unique`, `pluck`, `arrayify`, `getValue`, `sum`. There is no `first` or `last` helper: index the array (`{{record.items.[0].sku}}`) or use `@first` / `@last` inside `#each`. `reverse` reverses a string; on an array it renders empty. `lookup` isn't an array helper either; it reads a lookup defined on the same import step (`{{lookup.<name>}}`), and `getLookupCacheData` reads a lookup cache by its alias on the integration (`{{getLookupCacheData "skumap" record.sku}}`)
- **[Date/time](references/helpers/date.md)** -- `dateFormat`, `dateAdd`, `timestamp`
- **[Encoding](references/helpers/encoding.md)** -- `base64Encode`, `base64Decode`, `htmlEncode`, `htmlDecode`, `jsonEncode`, `jsonParse`, `jsonSerialize`, `encodeURI`, `decodeURI`, `stripProtocol`, `stripQuerystring`
- **[Regex](references/helpers/regex.md)** -- `regexMatch`, `regexReplace`, `regexSearch`
- **[Auth/crypto](references/helpers/auth.md)** -- `hash`, `hmac`, `aws4`
- **[Type/logic](references/helpers/type-logic.md)** -- `typeOf`, `eq`, `isTruthy`, `isFalsey`, `hasOwn`, `hasNoItems`, and the block helper `#compare`. There are no `gt`, `gte`, `lt` or `lte` helpers; compare with `#compare`
- **[Format](references/helpers/format.md)** -- `addCommas`, `bytes`, `ordinalize`
- **[Block helpers](references/helpers/block-helpers.md)** -- `#each`, `#if`, `#compare`, `#contains`, `#filter`, `#and`, `#or`, `#not`, `#neither`, `#ifEven`, `#unless`, `#with`, `#some`, `#startsWith`, `#inArray`, `#isEmpty`
Helper names are exact and case-sensitive: the case-conversion helpers are all lowercase (`camelcase`, not `camelCase`), and there's no `encodeURIComponent` (`encodeURI` already encodes `&`, `=` and `/`). A name that isn't a helper fails the render with 422 `Missing helper: "<name>"` once it has arguments (in a filter rule, 422 `Failed to evaluate handlebars expression`), but with none it's read as a field: a misspelled `{{timestmap}}` renders empty. The processor catalog (`celigo processors list`, the handlebars entry's `helperFunctions`) doesn't list every helper -- `eq`, `modulo`, `truncateWords` and `reverse` render although it omits them -- so check the index, and render a doubtful helper once (step 5).
### 5. Test the expression
Render the template first with the Handlebars processor (`POST /v1/processors/handlebars`): it saves nothing, runs no flow and calls no endpoint. It binds `data` as the template's root, so send the envelope the step would bind -- `{"record": {...}}`, `{"rows": [...]}` or `{"batch_of_records": [...]}`. Its output HTML-escapes double braces (`&` renders `&`) where a run URL-encodes, quotes or JSON-escapes them, so judge a double-brace value by a run. It also renders a null value empty where a run writes `null` (gotcha 16). It has no integration to read lookup caches from (`getLookupCacheData` answers 422), and the MCP server has no tool for it.
```bash
# Render against a sample record -- stateless, nothing saved or sent
echo '{"rules": {"template": "{{record.name}} | {{{record.name}}}", "resultMode": "text"},
"data": {"record": {"name": "Smith & Sons"}}}' | celigo processors invoke handlebars
# -> "data": "Smith & Sons | Smith & Sons"
# Invoke export to see if dynamic URI/query produces results
celigo exports invoke <exportId>
# Invoke import to validate body template renders correctly -- a real write:
# each piped record is sent to the destination, so point it at a test destination
echo '[{"orderId":"test-1"}]' | celigo imports invoke <importId>
```
## Common Patterns
### JSON comma separation in HTTP body templates
Avoid trailing commas when building JSON arrays:
```
{{#each record.items}}{...}{{#if @last}}{{else}},{{/if}}{{/each}}
```
### Batched, grouped and one-to-many records
When an HTTP i...
© celigo · MIT · celigo/ai