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

Each hook accepts three fields:

  • src — inline JavaScript, or a file:// path relative to the YAML file (cotctl inlines it on apply). Must define function run().
  • context — a comma-separated string of the contexts to inject (see below).
  • buttononly 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>.

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

See also