Skip to main content

Survey exec scripting

Exec scripting is how a survey does things a static form can't: pre-fill a field from the current user, validate an answer against a business rule, call an external API when a button is pressed. Each question can carry an exec block with small JavaScript functions that run at defined points in its lifecycle.

The code runs in the frontend, inside a Web Worker, with a 60-second timeout. Every script is a function run() (or async function run()) that always returns an array of commands — even an empty one.

The six lifecycle hooks​

- type: textinput
identifier: re_email
label: "Email"
exec:
preload:
context: "user#me"
src: |
function run() {
const email = context['user#me'].email;
return [{ cmd: 'SET_RESPONSES', value: [email] }];
}
HookFiresTypical use
preloadWhen the survey is created/loadedPre-fill fields, load remote data
onDisplayWhen the survey is opened for editingAdjust the UI, set required/read-only conditionally
onPlayWhen the user presses a custom buttonSearches, user-triggered lookups
validateBefore submitCustom validation — must return a RESULT
postsaveAfter savingPost-processing, logging
onSubmitSuccessAfter a successful submitWebhooks, external notifications

The order over a session is: preload → (user opens it) onDisplay → (button) onPlay → (submit) validate → postsave → onSubmitSuccess.

A stored question can carry three more stages — onChange, presave and filter — but the webclient never runs them. validate and every survey apply, dry run included, warn about them without refusing the file:

  • In the raw format such a stage is sent and stored as written, and one that carries code gets the warning. Its src is not asked for a function run(), since nothing runs it.
  • In the simplified format it is never sent, whatever it holds, so an apply also removes it from a question that stores it; any value gets the warning.

Each hook accepts three fields:

  • src — inline JavaScript, or a file:// path relative to the YAML file, which must stay inside that file's directory (cotctl inlines it on apply). Must define function run().
  • context — a comma-separated string of the contexts to inject (see below).
  • button — only valid on onPlay — configures the trigger button.

The onPlay button​

    onPlay:
context: "responses#self,user#me"
src: "file://./scripts/lookup.js"
button:
label: "Search"
type: flat # flat | stroked | default
theme: primary # basic | primary | accent | warn | ...
debounceTime: 2000 # milliseconds, minimum 1000

Contexts: what a script can read​

A script only sees the data you declare in its context field. Declare the contexts as a comma-separated string, then read each one by key: context['user#me'].

ContextGives you
user#meThe responding User object
user#companyThe company ID as a string (user.company._id)
channel#selfThe Channel running the survey
task#selfThe Task linked to the channel
message#selfThe Message that triggered the survey
property#channelThe channel's properties (an array)
property#userThe user's properties (an array)
responses#selfThis question's current answer

You can also read other answers in the same survey with responses#<identifier>, a parent survey's answers with responses#parent#<identifier>, and a sub-survey's answers with responses#<identifier>@<sub_survey_code>.

Exec on a table column​

A table column's exec runs in each cell, once per row, and differs from a question's in two ways:

  • Only preload and validate run in a cell. The webclient never runs a column's onDisplay, onPlay, postsave or onSubmitSuccess. Both formats still send them, so validate and every apply warn about each one that carries code or a context. A cell runs the column's preload with src alone, so it needs no context and draws no warning about a missing one.
  • The context sees only the cell's own row. responses#self is the cell, responses#<identifier> names another column of the same table, and responses#parent#<identifier> is looked up inside the row too. A root question, the table itself, or a column of another table resolves empty in the cell, and each gets its own warning. The static contexts (user#me, task#self, …) work as on a question.

None of these warnings changes the exit code.

In a transition survey, task#self is not populated. When a survey opens from a state-change transition (canChange: survey), only the channel-side contexts (channel#self, property#channel) carry data — the task isn't attached yet. Read what you need from those or from responses#self instead.

Commands: what a script returns​

run() returns an array of command objects that tell the survey what to do.

CommandShapeEffect
SET_RESPONSES{ cmd: 'SET_RESPONSES', value: [...], target?: 'self' \| <identifier> }Sets the saved answer (of this question, or another via target)
SET_READONLY{ cmd: 'SET_READONLY', value: 'true' \| 'false' }Locks/unlocks editing
SET_REQUIRED{ cmd: 'SET_REQUIRED', value: 'true' \| 'false' }Toggles required
RESULT{ cmd: 'RESULT', result: true \| false, value: 'message' }Controls validity (see below)

Note that SET_READONLY and SET_REQUIRED take the strings 'true'/'false', not booleans.

Validation with RESULT​

In a validate hook, RESULT is required. If your array doesn't contain one, the survey treats the question as invalid and blocks submission. A result: false blocks submit and shows value as the message — and short-circuits, so no other commands in the array run. A result: true lets submission proceed.

    validate:
context: "responses#self"
src: |
function run() {
const value = context['responses#self'];
if (!value || Number(value) < 0) {
return [{ cmd: 'RESULT', result: false, value: 'Must be zero or greater' }];
}
return [{ cmd: 'RESULT', result: true }];
}

In other hooks RESULT is optional and there's no short-circuit — the remaining commands always run.

Network requests​

Every script has a global networkRequest for authenticated HTTP calls, plus baseURL for building internal URLs.

async function run() {
const data = await networkRequest(
`${baseURL}/api/users`,
{ method: 'GET', headers: { 'Content-Type': 'application/json' } },
{ token: true } // injects the current user's Bearer token
);
if (!data) return []; // undefined on failure — always null-check
const name = data.data?.[0]?.name?.names ?? 'No data';
return [{ cmd: 'SET_RESPONSES', value: [name] }];
}

Key behaviors:

  • It never throws. On any failure — network error, 4xx, 5xx — it returns undefined. Guard with if (!data) return [];.
  • Pass { token: true } to attach the current user's JWT. Never hardcode a token.
  • A body object is auto-serialized with JSON.stringify — don't pre-stringify it.
  • There's no automatic retry and no request limit, but the 60-second worker timeout still applies.

Editing scripts as files​

Inline JavaScript inside YAML is awkward to write and review. Export with --extract-scripts to pull each script into its own .js file, referenced with file://:

cotctl surveys export my_survey -c acme -o my_survey.yaml --extract-scripts ./scripts/

On the next apply, cotctl inlines the file contents back in — so you get real editor tooling and clean diffs while the survey stays a single portable resource.

Two rules decide whether a reference is read (both since 0.14.0):

  • Only in a survey's script fields — the src of a hook on a question or a table column, and the survey's own src, editable.src and hidden.src. A file:// in any other src — an exec block at the survey's root, say, or a key that names no hook — keeps its text and is sent as written, with a warning.
  • Only inside the YAML file's directory. file://../…, an absolute path elsewhere, or a symlink that leads outside it is refused, exit 1. Keep the scripts folder under the folder that holds the YAML.

See file:// references for the full rules.

See also​