--- name: writing-scripts description: Write Celigo JavaScript hook scripts -- preSavePage, preMap, postMap, postSubmit, postResponseMap, filter, transform, branching, handleRequest. Use when creating or editing scripts, choosing the right hook point, understanding input/output data shapes, or debugging script behavior. --- # Writing Scripts A script is a **JavaScript function** that runs at a specific hook point in the Celigo data pipeline. Scripts handle logic that expressions, filters, and visual mappings cannot -- complex conditionals, cross-record calculations, API calls within the pipeline, and custom routing. Concerns when writing a script: - **Choosing the right hook point** -- which function type matches what you're trying to accomplish - **Input/output contracts** -- what `options` contains and what the function must return (array length rules are strict) - **Expression alternative** -- filter, transform, and output filter have expression-based alternatives that don't require a script; prefer expressions when possible - **Available modules** -- scripts can `import` three built-in modules: `integrator-api` (call Celigo APIs), `dayjs` (date/time manipulation), and `sjcl` (Stanford JavaScript Crypto Library for hashing/encryption) - **One script, many functions** -- a single script resource can contain multiple functions, each wired independently to different hook points Used across flows, APIs, and tools. Diagnosing a script is read-only. Editing it, rewiring it, and running a flow to test it all write to the account or to the connected systems, and an edit to a shared script reaches every hook that names it -- make those changes when the user asked for them, not as part of explaining a failure. A question about why something fails or how to fix it asks for the cause and the change you propose; make the change once the user says to. ## Hook Points Every script function runs at a specific point in the pipeline. Choose based on *when* you need to act and *what data* you need access to. ### Data Pipeline Hooks | Hook | Runs on | When | Input | Must return | |------|---------|------|-------|-------------| | `preParse` | File export | After the file is downloaded, before it's parsed | `options.preParseData` (the raw file text), `fileMeta` | `{ preParseData, fileMeta }` -- both, changed or not, for the parser to read | | `preSavePage` | Export (source or lookup) | Per page, after the export's transform and output filter | `options.data[]`, `errors[]`, `pageIndex`, `files[]`, `retryData{}` | `{ data[], errors[], abort, newErrorsAndRetryData[] }` | | `preMap` | Import | Before field mapping | `options.data[]` (unmapped records) | Array matching `data.length`, in order: `{ data: }` to send it, `{ errors }` to fail it, or `{}` to skip it -- a bare record is skipped too | | `postMap` | Import | After field mapping, before submit | `options.preMapData[]`, `postMapData[]` | Array matching `postMapData.length`: `{ data }`, `{ errors }`, or `{}` to skip | | `postSubmit` | Import | After the destination responds | `options.preMapData[]`, `postMapData[]`, `responseData[]` (one per record, below) | `responseData[]` (same length, modified); the response mapping reads the `_json` you return | | `postAggregate` | Import (a file import that aggregates records) | After the aggregated file is uploaded | `options.postAggregateData: { success, _json, code, message, source }` | void | **preSavePage returns an object, never a bare array.** Return `{ data, errors, abort, newErrorsAndRetryData }`, passing the page's `errors` through. A hook that returns the array itself -- `return options.data`, or a filtered copy -- drops the page without an error: the run completes with 0 records, 0 errors and 0 pages (live-verified 2026-10-21). **A throw in preSavePage fails its whole page.** When the hook throws on one record, every record on that page becomes a `script_error` (source `pre_save_page_hook`, each with its own retry data); the other pages run and the run still completes. The error count is therefore whole pages, not bad records: each failed page may hold a single bad record, and its errors share one message and one timestamp. **postSubmit gets one response per submitted record**, in order. Each `options.responseData[i]` carries: - `statusCode` -- 200 on success, 422 for a data error - `errors[]` -- `{ code, message, source }` entries, empty on success - `ignored` -- true when the record was skipped - `id` -- the id the destination returned (absent in test runs, and on a record that failed before it was sent) - `_json` -- the destination's parsed response body, where its own error detail sits (`_headers` holds the response headers) Return the same array, same length, with entries edited -- for example, an error message enriched from the body: ```javascript function addResponseReason (options) { return options.responseData.map((response) => { const reason = response._json && response._json.message if (response.errors && response.errors.length && reason) { response.errors = response.errors.map((error) => ({ ...error, message: `${error.message}: ${reason}` })) } return response }) } ``` **A postSubmit error comes after the write.** An error with source `post_submit_hook` belongs to a record the destination already accepted: the hook threw while handling its response. A retry sends the record again -- a duplicate, unless the destination refuses repeats -- and with the hook unchanged it fails the same way (live-verified 2026-10-22). So when asked to retry such errors, say this before doing it: fix the hook first, and retry only records the destination doesn't already hold. **preMap and postMap shape only the request this import sends.** Later steps never see their changes: the next step receives the record as it entered this import, plus what this step's response mapping merges. To give later steps a value, compute it where they can see it -- preSavePage on the source export (every step sees it), a postResponseMap hook on this step (the steps after it see it), or the later import's own preMap -- or map it from this step's response with response mapping. An element that carries both `data` and `errors` isn't a warning: the record is sent, but it gets the error (with retry data), counts as failed and goes no further -- so retrying that error sends it again. **preMap returns wrappers, not records.** Return one element per input record, in order: `{ data: record }` sends it, `{ errors: [{ code, message }] }` fails it. An element without `data` -- `{}`, or the record itself, as in `options.data.map((record) => { record.total = sum; return record })` -- skips that record silently: nothing is sent, no error is raised, and the record is counted as ignored (`ignored: true`, stage `preMapHook`). A return with more or fewer elements than `options.data` fails every record on the page with `invalid_extension_response` ("Extension result doesn't contain the same number of elements as the request object. Expected 2, got 1."), source `pre_map_hook` (live-verified 2026-10-22). Amounts often arrive as strings, so convert them before adding: `sum + line.amount` concatenates (`"0120.0045.50"`), which a number mapping refuses with `non_parsable_string` ("String contains alpha characters"), though a one-line order (`"0640.00"`) still parses. Write `sum + Number(line.amount)`; `parseInt` drops the cents. ### Record-Level Processors (on export or import) | Hook | When | Input | Must return | |------|------|-------|-------------| | `filter` | Per-record: an export's output filter (it sees the transformed record), or an import's input filter | `options.record` | `boolean` (true = process) | | `inputFilter` | Per-record on lookup exports | `options.record` | `boolean` (true = run the lookup; false = skip it, and the record continues without the lookup's data, counted as ignored) | | `transform` | Per-record reshaping of what an export returns -- source records, or a lookup's results (exports only) | `options.record` | Transformed record | Only `filter` runs on both exports and imports; `inputFilter` (lookups) and `transform` are export-only. Imports have no `transform` -- their response-side slot is `responseTransform`, which reshapes the destination's response before response mapping. **filter and transform have expression-based alternatives.** Only use a script when the logic is too complex for an expression (multi-field conditionals, date math, external lookups). ### Flow-Level Hook | Hook | Runs on | When | Input | Must return | |------|---------|------|-------|-------------| | `postResponseMap` | Page processor (flow/API/tool) | After response mapping merges results | `options.postResponseMapData[]`, `responseData[]` -- per record, an import's response as postSubmit returned it, or a lookup's `{ data: [], errors }`. Records the import failed are left out unless the step has `proceedOnFailure: true` | `postResponseMapData[]` (same length) | Configured on the flow's `pageProcessors[]` entry, not on the export/import. Plan this hook when building the resource, but wire it at the flow level. **postResponseMap sees failed records only with `proceedOnFailure`.** By default a record the import failed is in neither array, so every `responseData[i]` belongs to a record the destination accepted. With `proceedOnFailure: true` on the page processor, failed records are included: the record lacks the fields response mapping would have added, and its `responseData` entry has `statusCode` 422 and an `errors` that is an object keyed `"0"`, `"1"`, … rather than an array, so `errors.map` throws there. A successful entry has no `errors` at all. Read them with `Object.values(response.errors || {})` (live-verified 2026-10-22). ### Routing and Handlers | Hook | Runs on | When | Input | Must return | |------|---------|------|-------|-------------| | `branching` | Router | Per-record routing decision | `options.record`, `settings` | `number[]` of zero-based branch indices: one index (`[0]`) under `first_matching_branch`, where more than one fails the record with `invalid_script_routing`; several (`[0, 2]`) only under `all_matching_branches` | | `handleRequest` | API resource | Incoming HTTP request (script-mode API) | `options.method`, `headers` (no `Authorization`), `queryString` (the parsed query), `body` (a JSON or form body; absent otherwise), `rawBody` (a string). No `pathParams`, `queryParams` or `sandbox` | `{ statusCode, headers?, body }`, sent as returned (an array `body` too); no `statusCode`, or a `null` `body`, answers the caller 501, and a thrown error 422 | | `contentBasedFlowRouter` | AS2 or VAN connection | EDI message routing | `options.httpHeaders`, `mimeHeaders`, `rawMessageBody` (AS2) | `{ _flowId, _exportId }` | ## Quick Reference ### Hook Point Decision Matrix | When you need to... | Use hook | Configured on | Input / Output | |---|---|---|---| | Fix a file's raw text before it's parsed (a header row, a preamble, a format) | `preParse` | Export (file) | Receives the raw file text and its metadata, returns both | | Transform or filter a batch after retrieval | `preSavePage` | Export | Receives pages of records, returns pages (with optional errors) | | Filter individual records before processing | `filter` | Export or import | Receives single record, returns boolean (true = keep) | | Skip a lookup for some records | `inputFilter` | Export (lookup) | Receives single record, returns boolean (false = no lookup call; the record goes on unenriched) | | Reshape the records an export or lookup returns | `transform` | Export (source or lookup) | Receives single record, returns transformed record | | Change what an import sends, before field mapping | `preMap` | Import | Receives unmapped records array, returns array (same length) | | Change what an import sends, after field mapping | `postMap` | Import | Receives pre-map + post-map arrays, returns array (same length) | | Process API responses after submission | `postSubmit` | Import | Receives pre-map, post-map, and response arrays, returns response array | | Handle results after file aggregation | `postAggregate` | Import (file) | Receives aggregation result, returns void | | Post-response processing (merge lookup/import results) | `postResponseMap` | Flow `pageProcessors[]` entry | Receives merged records + response data, returns merged records (same length) | | Route records to branches | `branching` | Router in flow/tool | Receives single record + settings, returns branch indices array | | Handle incoming HTTP requests (script-mode API) | `handleRequest` | API resource | Receives method, headers, query, body; returns `{ statusCode, headers?, body }` | | Route EDI messages to flows | `contentBasedFlowRouter` | AS2 or VAN connection | Receives HTTP/MIME headers + raw body (AS2), returns `{ _flowId, _exportId }` | ### Minimum Required Fields A script resource needs only two fields: - `name` -- descriptive name (convention: ` - - `, e.g., `"Salesforce - getBatchRecords - postResponseMap"`) - `content` -- the JavaScript source code as a string See [references/schemas/request.yml](references/schemas/request.yml) for the full create/update schema. ## Related Skills - [configuring-exports > Quick Reference](../configuring-exports/SKILL.md#quick-reference) -- export configuration, where `preSavePage`, `filter`, `transform`, and `inputFilter` hooks are wired - [configuring-imports > Quick Reference](../configuring-imports/SKILL.md#quick-reference) -- import configuration, where `preMap`, `postMap`, `postSubmit`, and `postAggregate` hooks are wired - [building-flows > How to Build a Flow](../building-flows/SKILL.md#how-to-build-a-flow) -- flow construction, where `postResponseMap` and `branching` hooks are wired - [writing-handlebars > Quick Reference](../writing-handlebars/SKILL.md#quick-reference) -- Handlebars expressions for dynamic values in scripts and hook configurations - [troubleshooting-flows > 7. Low-level debug primitives](../troubleshooting-flows/SKILL.md#7-low-level-debug-primitives-surgical-control) -- reading a hook's stage in a run's execution log ## Common Options Available to All Hooks The batch hooks (preSavePage, preMap, postMap, postSubmit, postResponseMap) receive these context fields in `options`: - `_flowId`, `_integrationId`, `_apiId`, `_parentIntegrationId` -- execution context IDs - `_exportId` or `_importId` -- the step's resource ID - `_connectionId` -- the connection in use - `configuration` -- the static object on the hook reference ([5. Wire the script](#5-wire-the-script-to-the-resource)) - `settings` -- the settings of every resource in the step's chain, under fixed scope keys (`.integration`, `.flowGrouping`, `.flow`, `.connection`, `.iClient`, and the step's own `.export` or `.import`): read `options.settings.flow.batchSize`, never `options.settings.batchSize`. A scope whose resource has no settings can be missing entirely, so guard it: `(options.settings.flow || {}).batchSize`. On a step of an API or a Tool the options are narrower (seen on a lookup's `postResponseMap`): `settings` holds only the step's own scope, with no `.integration`, and there's no `job`, `_flowId` or `_integrationId` (an API's step gets `_apiId`), so pass anything else on the record - `testMode` -- boolean, whether running in test/preview mode - `sandbox` -- boolean, whether the script runs in a sandbox environment - `job` -- the current job object - preSavePage also gets `pageIndex` (the page's position, from 0) and, on a delta export, `lastExportDateTime` and `currentExportDateTime` Record-level functions get far less -- in a real run, no resource IDs and no `testMode`: a script transform gets `record`, `settings` and `job`; an export's script filter adds `pageIndex` (and the delta dates on a delta export); a lookup's input filter gets `record`, `settings`, `job` and `pageIndex`; a branching function only `record` and `settings` -- and its `settings` has no step scope, because a router isn't a step. ## Function Point Categories Scripts run at the function points below, grouped into four categories. The [Hook Points](#hook-points) tables above give each one's input/output contract; this is the mental model for *which kind* of point you're wiring and whether a non-script alternative exists. - **Step-level pipeline hooks** (on the export or import) -- `preParse`, `preSavePage`, `preMap`, `postMap`, `postSubmit`, `postAggregate`. A Tool's `output` and a builder API's responses carry their own `hooks.preMap` and `hooks.postMap` around their mappings. - **Parent-level response hook** (on the flow/API/tool `pageProcessors[]` entry, not the step) -- `postResponseMap` - **Script-mode replacements for declarative slots** -- `filter`, `inputFilter`, `transform`, `branching`, and a builder API's response router (`routeRecordsUsing: "script"`; the function returns the response `id`) - **Resource-specific function points** -- `contentBasedFlowRouter` (on an AS2 or VAN connection) and `handleRequest` (on a script-mode API) Two more sit outside the data pipeline, mostly in Integration App authoring: a settings form's `init` (returns the form to render) and a resource's `preSave` on an export, import or integration (returns `options.newResource`; a throw blocks the save). **Script-only points have no declarative equivalent:** `preParse`, `postSubmit`, `postResponseMap`, `postAggregate`, `contentBasedFlowRouter`, and `handleRequest`. On those slots a script is the only option. The four script-mode slots (`filter`, `inputFilter`, `transform`, `branching`) each hold *either* a declarative rule tree *or* a script -- never both -- so prefer the declarative path there unless the logic genuinely can't be expressed as rules (see [Declarative vs Script Mode](#declarative-vs-script-mode)). ## Declarative vs Script Mode The four mode-switchable slots -- `filter`, `inputFilter`, `transform`, and `branching` -- hold a declarative rule tree or a script reference at any one moment, not both. Because the slot's contents change, switching modes is a two-part operation. **From script mode to declarative mode** (the common direction -- prototype with a script, then clean up): 1. Clear the script from the slot. The slot reverts to declarative mode by default. 2. Author the declarative rule for that slot (rules-engine filter, Mapper 2.0 transform, or router input-filter rule). **From declarative mode to script mode** (rarer -- the rules engine couldn't express what you need): 1. Wire a script into the slot. The declarative rules already there are replaced by the script reference automatically. Wiring a script and clearing it are mirror operations on the same slot. Recognize the mode-swap in phrasing like *"switch the filter to rules"*, *"convert this transform back to expressions"*, or *"use a script for this filter instead of rules"*. ## How to Write a Script ### 1. Determine what you need to accomplish Map your goal to the right hook point using the [Hook Point Decision Matrix](#hook-point-decision-matrix) above. ### 2. Check if an expression can handle it Filter, transform, and output filter all have expression-based alternatives. Expressions are simpler to maintain and don't require a script resource. Use a script only when you need: - Multi-step logic or loops - Cross-record calculations (totals, deduplication) - External API calls via `integrator-api` - Error handling with retry data - Access to `preMapData` alongside `postMapData` ### 3. Check for existing scripts in the account ```bash celigo scripts list celigo scripts get # content is only returned on individual GET ``` ### 4. Create the script resource A hook's `function` can name any top-level function in the script; what must match, exactly and case-sensitively, is that name and a function the script declares. Plain function declarations work, and a single script can contain multiple functions. See [references/schemas/request.yml](references/schemas/request.yml) for the create/update schema and [references/schemas/response.yml](references/schemas/response.yml) for the response shape. Key fields: - `name` -- descriptive name (convention: ` - - `, e.g., `"Salesforce - getBatchRecords - postResponseMap"`) - `content` -- the JavaScript source code To add a function to a script that other hooks already use, read the script, add the function to its `content`, and send the whole `content` back with the existing functions in it -- an update replaces `content` whole, and a hook whose function is gone fails. Or put the new function in a script of its own. ### 5. Wire the script to the resource Wiring depends on the hook type: | Hook | Wiring pattern | Where | |------|---------------|-------| | `preParse`, `preSavePage` | `hooks.{hookType}: { _scriptId, function, configuration? }` | Export resource | | `preMap`, `postMap`, `postSubmit`, `postAggregate` | `hooks.{hookType}: { _scriptId, function, configuration? }` | Import resource | | `filter`, `inputFilter`, `transform` | `{field}: { type: "script", script: { _scriptId, function } }` | `filter`: an export's output filter or an import's input filter; `inputFilter`: a lookup export's input filter; `transform`: exports only | | `postResponseMap` | `hooks.postResponseMap: { _scriptId, function }` | Flow `pageProcessors[]` entry | | `branching` | `routeRecordsUsing: "script"` + script reference | Router in flow | | `handleRequest` | `script: { _scriptId, function }` on an API created with `type: "script"` (an API's `type` can't change after creation) | API resource | | `contentBasedFlowRouter` | `as2.contentBasedFlowRouter` or `van.contentBasedFlowRouter`: `{ _scriptId, function }` | AS2 or VAN connection | **Hook-based** attachment (preSavePage, preMap, etc.) adds one key under the resource's `hooks`, beside the hooks already there -- but over the API it is a full update of the export or import. Read the whole document, add the key to its `hooks`, and send the complete document back: a body whose `hooks` holds only the new hook removes the others (a `preMap` dropped while adding `postSubmit`), and a body without the request settings or connection clears those too. Only a debug or metadata field of an export or import can change on its own, so a refused single-field change to `hooks` means "send the full update", not that hooks can't be set over the API. **Replace-based** attachment (filter, transform) replaces the existing filter/transform expression. A hook reference's optional `configuration` is a static object the function receives as `options.configuration`, so one function can serve several steps with different values. A hook can name a stack (`_stackId`) instead of a script -- see [managing-stacks > Referencing a Stack](../managing-stacks/SKILL.md#referencing-a-stack-_stackid). Every `_scriptId` must name a script in this account. An Integration App's resources (`_connectorId` set) take only the app's own scripts, so wiring one of yours into an app's export, import or flow is refused with 422 `invalid_ref` ("Only a script owned by the user or a script shared by the Integration App can be used at ."), the path naming the field, such as `hooks.preMap._scriptId`. Put your own logic in a companion flow instead. ### 6. Test and iterate ```bash # Enable debug logging on the script celigo scripts enable-debug # Run the flow or API that triggers the script celigo flows run -y # Check debug logs celigo scripts debug-logs --since 30 # Check execution logs celigo scripts debug-logs --level ERROR --limit 20 # Disable debug when done celigo scripts disable-debug ``` Each kind of run treats hooks differently: - **An export preview** (`celigo exports invoke` with a document) runs the export's transform, output filter and preSavePage hook, each as a stage: `preSavePageHook` receives only the records the filter passed, and the preview's `data` is what it returned. - **A flow test run** runs hooks on the first page only (later pages arrive empty) and never sends imports, so import hooks see the saved `mockResponse` (or a placeholder) instead of a real response. It writes no script log entries. - **A real run** is the only kind that writes the script's log. **To run one function without a flow,** send it to the JavaScript processor -- the platform's script runner -- with the code and the `options` the function should receive, such as `input[0]` from its hook stage in a debug-level run ([Authoring Against Sample Data](#authoring-against-sample-data)): ```bash celigo processors invoke javascript --file run.json # run.json: {"rules": {"function": "", "code": "