Skip to content

Actions ​

An action is your own code, run by Hailer when something happens in a workflow, or on a schedule. When an order moves to Approved, send an e-mail. When a ticket is created, look the customer up in your CRM and fill in the missing fields. When a delivery is marked done, create the invoice in another workflow. Every weekday morning, chase the tickets nobody has touched. Anything you can do with the Hailer API or a third-party API, an action can do automatically.

The code runs in an isolated, throwaway sandbox. It has no network access beyond the hosts you allow, and secrets reach it as environment variables from the workspace credential vault, so nothing sensitive lives in the code itself. Every run leaves a record with its output, logs and status.

Actions are managed by workspace admins and owners, in the workspace settings or through the v3.action endpoints. This guide explains how the pieces fit together.

Your first action ​

The example below posts a message into an activity's discussion whenever the activity arrives in a phase. It touches every piece you will use: a credential, the code, the phase trigger, a test run and the run history.

1. Create a user API key. An action has no logged-in user, so to call the Hailer API it needs a key of its own. Create one in your user settings, for the user the action should act as. Everything the action does in Hailer is done as that user, with that user's permissions, so a dedicated automation user keeps the audit trail clear. The key is shown once - copy it now.

2. Store the key in the credential vault. In the workspace settings, add a credential with a single variable, for example HAILER_API_KEY, and paste the key as its value. Actions read credentials as environment variables, so the variable name is what the code will use.

3. Create the action. Give it a name, bind the credential, allow api.hailer.com as a network host, declare @hailer/cli as a dependency, and paste the code:

json
{ "imports": { "@hailer/cli": "npm:@hailer/cli@1.2.1" } }
typescript
import { Client } from "@hailer/cli";

export async function execute(input) {
  const hailer = await Client.create({
    userApiKey: process.env.HAILER_API_KEY,
  });
  try {
    // messenger.send fails with "Unable to save message." (code 127) unless the sender is a
    // member of the discussion. Following the activity joins its discussion.
    const { user } = await hailer.request("v2.core.init", [["user"]]);
    for (const activity of input.payload.activities) {
      if (!activity.discussionId) continue;
      await hailer.request("v3.activity.updateMany", [
        [{ _id: activity._id, followers: { [user._id]: true } }],
        { ignoreRequired: true },
      ]);
      await hailer.request("messenger.send", [
        { msg: `${activity.name} is now being handled.` },
        activity.discussionId,
      ]);
    }
    return { messaged: input.payload.activities.length };
  } finally {
    hailer.disconnect();
  }
}

4. Try it. Use the editor's run button with an existing activity. The activity is delivered exactly as a phase trigger would deliver it, and the result shows the status, the return value and anything the code logged.

5. Bind it to a phase. In the action's "Where it runs" section, or in the phase's settings in the workflow editor, add the phase and tick when activities are added. From now on every activity created in or moved into that phase gets the message.

6. Watch it work. The action's run list shows every run with its status, output and logs.

The rest of this guide goes through each part in detail.

How an action is built ​

FieldPurpose
nameShown in the UI and in run records. Max 128 characters.
codeThe program. Must export an execute(input) function (or a default function).
packageJsonOptional package.json content, stored verbatim, declaring the code's npm dependencies.
allowNetOptional list of hosts the run may reach, e.g. ["api.example.com"]. Default: no network at all.
credentialIdsOptional workspace credentials injected as environment variables.
limitsOptional { wallTimeMs } - the time budget for one run, 1 s to 10 min. Default 60 s.
triggersOptional event keys the action is bound to, e.g. phase.activity-created:<workflowId>:<phaseId> or workspace.member-joined. Max 32. See Triggers.
scheduleOptional { cron, tz? } - one five-field cron expression, read in tz or UTC. See Scheduled runs.
maxRunsPerHourOptional hourly budget for event-triggered runs. Default 1000, max 1 000 000. See Limits and billing.
enabledThe kill switch. A disabled action refuses every trigger: a manual run is recorded as config_error but a test run is permitted, phase events skip it silently. Default true.
descriptionOptional free text. Max 2048 characters.

Over the API, v3.action.create takes these fields in one object. v3.action.update changes only the fields you pass; null clears description, packageJson, allowNet, limits, maxRunsPerHour, triggers or schedule. v3.action.remove deletes the action and detaches it from every phase that referenced it. Run records are kept as the workspace's history.

Writing the code ​

Your code is a TypeScript module. It is written to a file in a fresh, sandboxed working directory, imported as a module, and its execute export is called once with the run's input:

typescript
export async function execute(input: unknown) {
  // do work
  return { ok: true };
}
  • Return value - JSON-serialised and stored as the run's output. Returning nothing stores null, and so does a value that cannot be serialised (a function, a circular object) - return plain data.
  • Logging - console.log goes to stdout, console.error to stderr. Both are stored on the run record, capped at 1 MB each. Crossing a cap kills the run with status output_limit.
  • Failure - an uncaught exception or a non-zero exit ends the run as runtime_error. The stack trace is in stderr.
  • Time - a run that exceeds its wallTimeMs is killed with status timeout. Dependency installation is not counted.

Nobody is logged in ​

An action runs on its own, with no user session. The user who caused the trigger is only an id in the input (triggeredBy); the action cannot act as them. To read or change anything in Hailer, the code logs in itself with a user API key from a credential, using @hailer/cli.

typescript
import { Client } from "@hailer/cli";

export async function execute(input) {
  const hailer = await Client.create({
    userApiKey: process.env.HAILER_API_KEY,
  });
  try {
    const activity = input.payload.activities[0];
    await hailer.request("v3.activity.updateMany", [
      [{ _id: activity._id }],
      { fields: { "6a9e7d3e3a088f5c1e7cbc03": "processed" } },
    ]);
    return { updated: activity._id };
  } finally {
    hailer.disconnect();
  }
}

This needs @hailer/cli in the packageJson dependencies, api.hailer.com in allowNet (Client.create connects there by default; pass host for another environment), and a bound credential holding the key. Every endpoint in the API reference is available through hailer.request.

Always call disconnect() when done, in a finally block. The client keeps a socket open, and an open socket keeps the process alive after execute returns - the run would then sit idle until the wallTimeMs kill and end as timeout.

The sandbox is deliberately narrow ​

  • No network unless the host is in allowNet. A fetch to any other host fails with a permission error.
  • File reads and writes are limited to the run's own working directory. Nothing persists between runs.
  • No subprocesses and no FFI.
  • Imports resolve only from dependencies declared in packageJson. An undeclared import fails at startup.

Dependencies ​

Declare them exactly as you would in a local package.json, then import by package name. A Slack notifier, for example, declares the Slack SDK, allows slack.com, and reads its bot token from a credential:

json
{ "dependencies": { "@slack/web-api": "^7" } }
typescript
import { WebClient } from "@slack/web-api";

export async function execute(input) {
  const slack = new WebClient(process.env.SLACK_TOKEN);
  for (const activity of input.payload.activities) {
    await slack.chat.postMessage({
      channel: "#ops",
      text: `New: ${activity.name}`,
    });
  }
  return { posted: input.payload.activities.length };
}

Dependencies are installed before the code runs. Installation has its own time budget and does not consume the action's wallTimeMs. A failed install (bad JSON, unknown package) ends the run as runtime_error with the installer's output in stderr and message; an install that exceeds its budget ends it as timeout.

Credentials ​

Secrets are stored in the workspace credential vault and bound to the action by id. A credential is a named set of variables - SLACK_TOKEN, HAILER_API_KEY, CRM_URL - and belongs either to the workspace or to you personally. Only workspace credentials can be bound to an action. Over the API they are managed with v3.credential.create, v3.credential.update and v3.credential.remove.

At run time every variable of every bound credential becomes one environment variable, read with process.env.SLACK_TOKEN. String values are passed as-is, anything else as JSON. When two credentials define the same variable, the one later in credentialIds wins.

Use uppercase names with underscores, as for any environment variable. Names starting with DENO_ and names the runtime itself uses, such as PATH or HOME, are rejected. A run whose credentials break these rules ends as config_error before anything executes, and so does a run whose credential was removed after binding.

Environment variables are visible to your code and to its dependencies alike. allowNet is the guard against a dependency leaking them - keep the list to the hosts the action actually needs.

Triggers ​

Whatever starts a run, execute(input) always receives the same envelope:

json
{ "event": "phase.activity-created", "payload": { "...": "event-specific" }, "workspaceId": "...", "triggeredBy": "..." }

event says what happened, payload carries only the object of that event, workspaceId is always present, and triggeredBy is the user whose action caused the event when there is one - a scheduled run has none. Code reads input.event and input.payload and never has to know how it was started.

Started byevent / payload
The run button, or v3.action.triggermanual / the input argument as given
An activity created in, moved into, or updated in a bound phasephase.activity-created, phase.activity-updated or phase.activity-moved / the activities, below
A member joining or leaving the workspace, when the action has a workspace.* triggerworkspace.member-joined or workspace.member-left / the member, below
The action's schedule cron expression coming dueschedule.run / { scheduledFor, firedAt }, below
The editor's run button, or v3.action.runmanual / your input, or phase.activity-created / one activity as a phase trigger sends it, or workspace.member-* / one member as a membership trigger sends them

Manual runs ​

javascript
const run = await client.request("v3.action.trigger", [
  { actionId },
  { customerId: "42" },
]);
console.log(run.status, run.output);

The code receives { event: "manual", payload: { customerId: "42" }, workspaceId, triggeredBy }.

The call waits up to 25 seconds. A run that finishes in time answers with its final record. A longer run answers with the record as it stands (queued or running) and finishes in the background; fetch the outcome later with v3.action.getRun or listen for the action.run.updated signal (see Runs).

Who may trigger. Admins and owners always can. To let others, set runners on the action: a list of network_<id>, team_<id>, group_<id> or user_<id> entries, the same ids app members use. network_<workspaceId> opens the action to every member. A runner gets the outcome only - status, output, message, exitCode - never the config, stdout or stderr. The same applies when a runner fetches one of their own runs with v3.action.getRun.

Addressing by key. Give the action a key (lowercase letters, digits and dashes, unique within the workspace) and trigger it with { workspaceId, key } instead of the id. Handy when a script creates the same action in several workspaces and callers should not have to look up ids.

Public actions. Set public: true and anyone holding the action's publicKey can trigger it without logging in, either over the API or as a plain webhook:

javascript
await client.request("v3.action.trigger", [{ publicKey }, { orderId: "42" }]);
POST https://api.hailer.com/public/action/<publicKey>
Content-Type: application/json

{ "orderId": "42" }

The JSON body is the input; the response is the run outcome as a runner sees it. The run has no triggeredBy and its tokens are charged to the action's author. The key is generated when the action is created; rotatePublicKey: true on v3.action.update replaces it and the old one stops working at once. Public callers are throttled per key, 30 requests a minute.

Manual runs count against maxRunsPerHour like any other, so a flood of public or runner calls can exhaust the budget and block the action's phase, workspace and scheduled runs until the window moves on. Treat the key as a secret: rotate it or set enabled: false if it leaks.

Phase triggers ​

An action is bound to a workflow phase through its triggers: in the action's "Where it runs" section, in the phase's settings in the workflow editor, or with v3.action.update. A trigger is a string key: the event name, then the workflow id and the phase id, joined with :. v3.action.triggers lists the events and the parameters each key takes.

javascript
await client.request("v3.action.update", [
  greetActionId,
  {
    triggers: [
      `phase.activity-created:${workflowId}:${phaseId}`, // created in this phase
      `phase.activity-moved:${workflowId}:${phaseId}`, // ...or moved into it
    ],
  },
]);
  • triggers replaces the whole list; null clears it. The phase must exist in this workspace, otherwise the call fails with InvalidArguments and nothing is written. A duplicate key or an unknown event fails the same way.
  • Up to 32 triggers per action; one action can be bound to any number of phases.
  • Removing a phase or a workflow removes its triggers from every action. enabled: false on the action pauses all of its triggers without removing them.

Phase runs fire for events caused by any workspace member, not only admins. The run is attributed to the member whose change caused it.

When one event touches several activities in the same phase (a bulk create, a multi-select move), the action runs once per event with all matching activities batched into one input:

json
{
  "event": "phase.activity-moved",
  "payload": {
    "activities": [
      {
        "_id": "6a9e7d4a3a088f5c1e7cbcf5",
        "name": "Order 1042",
        "workflowId": "6a9e7d3e3a088f5c1e7cbc06",
        "phaseId": "6a9e7d3e3a088f5c1e7cbc05",
        "phaseKey": "processing",
        "workspaceId": "6a9e7d2b3a088f5c1e7cbae3",
        "teamId": "6a9e7d2b3a088f5c1e7cbae4",
        "followerIds": [],
        "discussionId": "6a9e7d4a3a088f5c1e7cbcf6",
        "createdBy": "6a9e7d2b3a088f5c1e7cbae2",
        "created": 1788771658072,
        "updated": 1788865042073,
        "files": [],
        "fields": {
          "customer": "Acme Oy",
          "6a9e7d3e3a088f5c1e7cbc03": "a field without a key is keyed by its fieldId"
        }
      }
    ]
  },
  "workspaceId": "6a9e7d2b3a088f5c1e7cbae3",
  "triggeredBy": "6a9e7d2b3a088f5c1e7cbae2"
}
  • event is phase.activity-created, phase.activity-updated or phase.activity-moved.
  • payload.activities are the activities after the change. Field values are flattened to { [key or fieldId]: value }: fields with a key in the workflow are addressed by that key, others by their id. Fields with no value are omitted. files lists the activity's file ids. phaseKey is the current phase's key, present when the phase has one.
  • triggeredBy is the user whose change caused the event.

The payload also has a changes object keyed by activity id for activities with change detail to report: files when the saved update carried file changes, with files.added, files.removed and files.current maps of file id to { _id, type, name }; and on phase.activity-moved a previousPhase with the _id of the phase the activity left and its key when the phase has one.

A phase action that writes back to its own activity fires its own phase.activity-updated event again. Such loops cannot be detected, only bounded: every action has an hourly budget for event-triggered runs, maxRunsPerHour (see Limits and billing).

Workspace triggers ​

Membership events are bound the same way, with workspace.* keys that take no parameters:

javascript
await client.request("v3.action.update", [
  onboardActionId,
  {
    triggers: [
      "workspace.member-joined", // a member joined
      "workspace.member-left", // ...or left
    ],
  },
]);

workspace.member-joined fires once per invite consumed, on every way a user can join:

  • an existing user accepts an invitation (v2.user.invite.accept),
  • a new user registers through an invitation link (v2.user.invite.register),
  • an invitation is sent to an existing user who has no workspace yet - they are joined at once, with no accept step (v2.network.invite.send).

Creating a workspace does not fire it for the creator. The run is attributed to the user who joined.

json
{
  "event": "workspace.member-joined",
  "payload": {
    "user": {
      "_id": "6a9e7d2b3a088f5c1e7cbae2",
      "firstname": "Maija",
      "lastname": "Meikäläinen"
    },
    "invite": {
      "_id": "6a9e7d4a3a088f5c1e7cbcf5",
      "workspaceId": "6a9e7d2b3a088f5c1e7cbae3",
      "uid": "6a9e7d2b3a088f5c1e7cbae2",
      "name": "Maija Meikäläinen",
      "title": "Sales",
      "teams": ["6a9e7d2b3a088f5c1e7cbae4"],
      "role": "user",
      "invitedBy": "6a9e7d2b3a088f5c1e7cbae1",
      "created": 1788771658072
    }
  },
  "workspaceId": "6a9e7d2b3a088f5c1e7cbae3",
  "triggeredBy": "6a9e7d2b3a088f5c1e7cbae2"
}
  • payload.user is the member as they are now: _id, firstname, lastname.
  • payload.invite is a snapshot of the invitation they consumed - the invite itself is deleted on join. Optional fields (title, notes, role, customRole, discussionIds) appear only when the invitation set them. role is absent for a plain member. On the auto-join path no invitation document is ever stored, so invite._id is absent there.
  • triggeredBy is the new member.

workspace.member-left fires once whenever a member is pulled from the workspace, whether they left themselves (company.leave) or an admin removed them (company.remove_user). It is one event, payload.removedBy tells the two apart. Deleting a whole workspace does not fire it.

json
{
  "event": "workspace.member-left",
  "payload": {
    "user": {
      "_id": "6a9e7d2b3a088f5c1e7cbae2",
      "firstname": "Maija",
      "lastname": "Meikäläinen"
    },
    "removedBy": "6a9e7d2b3a088f5c1e7cbae1"
  },
  "workspaceId": "6a9e7d2b3a088f5c1e7cbae3",
  "triggeredBy": "6a9e7d2b3a088f5c1e7cbae1"
}
  • payload.user is the former member. By the time the action runs they are no longer in the workspace, so looking them up as a workspace member fails; the payload is what you have.
  • payload.removedBy and triggeredBy are the same user: the member themselves on leave, the admin on removal. The run is attributed and billed to that user.

Membership runs fire in the background after the join or removal has completed; they never delay or fail the join itself. An action bound to joined that sends invitations fires itself for every user who accepts - the same maxRunsPerHour budget bounds it.

Scheduled runs ​

An action runs on a timetable through its schedule, a field of its own next to triggers. It holds one five-field cron expression (minute, hour, day of month, month, day of week) and, optionally, the IANA timezone the expression is read in. Without tz the expression is read in UTC. An action has at most one schedule.

javascript
await client.request("v3.action.update", [
  chaseActionId,
  {
    schedule: { cron: "0 9 * * 1-5", tz: "Europe/Helsinki" }, // weekdays at 09:00 Helsinki time
  },
]);
  • The expression is validated when the action is saved. A malformed pattern, a six-field pattern, an unknown timezone, or an expression that never fires again (0 0 30 2 *) fails with InvalidArguments and nothing is written.
  • The stored schedule carries nextRunAt, the next firing in epoch milliseconds, computed by Hailer when the action is created and whenever schedule is replaced. It is read-only: sending it in schedule fails with InvalidArguments. Read it back with v3.action.get to see when the action fires next.
  • Replacing schedule restarts it from now, even with the same cron and tz. schedule: null removes it.

Schedules are checked once a minute. When a schedule is due, exactly one run starts, whatever the number of API replicas, and the schedule advances to its next firing. The code receives the slot the expression matched and the moment the run actually started:

json
{
  "event": "schedule.run",
  "payload": {
    "scheduledFor": 1788771600000,
    "firedAt": 1788771603412
  },
  "workspaceId": "6a9e7d2b3a088f5c1e7cbae3"
}
  • payload.scheduledFor is the minute the expression matched. payload.firedAt is when Hailer started the run; normally a few seconds later. If the platform missed a tick, the slot is fired late, once, and firedAt is correspondingly further behind scheduledFor. Slots are never fired twice and several missed slots are never fired in a burst - the schedule simply moves on to the next firing after the one that ran.
  • There is no triggeredBy. Nobody caused a scheduled run, so the envelope has no user and the run record's meta has none either. Code that reads input.triggeredBy must handle its absence. The run, and its token cost, are attributed to the user who created the action.
  • While the action is disabled nothing fires and the schedule stands still. Re-enabling it restarts the schedule from now; the slot missed while it was off is skipped, not fired late. Passing schedule: null together with enabled: true clears it instead.
  • Scheduled runs count against maxRunsPerHour like phase and workspace runs. Once the budget is exhausted a due firing is recorded as denied (see Limits and billing).

A scheduled action typically queries Hailer itself for what to work on, since no activity or member arrives with the event:

typescript
import { Client } from "@hailer/cli";

export async function execute(input) {
  const hailer = await Client.create({
    userApiKey: process.env.HAILER_API_KEY,
  });
  try {
    const now = input.event === "schedule.run" ? input.payload.scheduledFor : Date.now();
    const { activities } = await hailer.request("v3.activity.list", [
      { processId: "6a9e7d3e3a088f5c1e7cbc06", phaseId: "6a9e7d3e3a088f5c1e7cbc05" },
      { sortBy: "updated", sortOrder: "asc", limit: 100 },
    ]);
    const stale = activities.filter((activity) => activity.updated < now - 24 * 60 * 60 * 1000);
    // ...remind the owners
    return { chased: stale.length };
  } finally {
    hailer.disconnect();
  }
}

Testing a schedule. The editor's run button and v3.action.run have no schedule fixture - they cannot deliver a schedule.run event. Test the code with an input of your own (delivered as a manual event) and branch on input.event as above, or wait for the next nextRunAt and read the run record.

Test runs ​

The editor's run button executes the code as it is in the editor, saved or not, through v3.action.run. Give it one of: an input of your own, delivered as the payload of a manual event; an activityId to receive that activity exactly as a phase trigger delivers it (a one-element phase.activity-created event); or member: { userId, event: 'joined' | 'left' } to receive that workspace member exactly as a membership trigger delivers them (workspace.member-joined or workspace.member-left). No invite exists for a test, so the joined payload carries a synthesised invite without _id, and the caller stands in as invitedBy / removedBy. A schedule.run event cannot be simulated; see Scheduled runs. Test runs use the same sandbox, credential rules and billing as saved actions, and they run even when the action is disabled. The executed code and settings are snapshotted on the run record, since no saved action may exist for them.

Runs ​

Every execution leaves a durable run record. Its status moves through the lifecycle queued → running → <final>:

StatusMeaning
queuedAccepted, waiting for a sandbox slot.
runningExecuting.
completedexecute returned; output holds the return value, exitCode is 0.
runtime_errorThe code threw or exited non-zero. See stderr.
timeoutKilled after wallTimeMs, or the dependency install exceeded its own budget.
output_limitKilled for exceeding the stdout/stderr cap.
cancelledCancelled while still queued.
config_errorNever started: the action is disabled, or a bound credential was missing, undecryptable, or had an invalid key.
deniedNever started: the workspace has no tokens, or the action's maxRunsPerHour budget is exhausted.
infra_errorThe platform failed, not your code. Not billed.

The record also carries event (the event it delivered: manual, phase.*, workspace.* or schedule.run), test (set when the editor ran inline code rather than the saved action), meta (who triggered it, for phase runs the activity ids, for scheduled runs scheduledFor and firedAt and no triggeredBy), stdout, stderr, output, message (human-readable detail for abnormal statuses), wallTimeMs, created and finishedAt. Timestamps are epoch milliseconds.

Debugging - when a run does not do what you expected, read its record: stderr has the stack trace of a runtime_error, message explains a timeout, config_error or infra_error, and stdout has whatever you logged.

Retries - a run is never retried automatically. If the platform fails mid-run (infra_error), the outcome of your code is unknown: it may or may not have completed its work. Design actions so that running them twice is harmless, and trigger the run again yourself.

Listing - v3.action.runs returns a workspace's runs newest first, filterable by action, status and created range, paged with limit (max 100) and skip:

javascript
const failed = await client.request("v3.action.runs", [
  { workspaceId, actionId, status: ["runtime_error", "timeout"], limit: 20 },
]);

Live updates - every status change broadcasts an action.run.updated signal to the workspace:

json
{
  "sig": "action.run.updated",
  "meta": {
    "workspaceId": "...",
    "runId": "...",
    "actionId": "...",
    "event": "phase.activity-moved",
    "status": "completed"
  }
}

The signal carries ids, the event and the status only (test: true is added for editor runs). Fetch the full record with v3.action.getRun - runs are visible to admins and owners, while the signal reaches every member.

Cancelling - v3.action.cancelRun stops a run that is still queued. A run that has started cannot be cancelled; the call fails with the run's current status.

Limits and billing ​

LimitValue
Wall time per runlimits.wallTimeMs, default 60 s, max 10 min
stdout / stderr1 MB each; crossing it ends the run as output_limit
MemoryFixed by the platform
Code / package.json size512 KB / 64 KB
allowNet entries / credentials32 / 16 per action
Event-triggered runs per actionmaxRunsPerHour, default 1000, max 1 000 000

Sandbox time is paid in workspace tokens: one token per started second of wallTimeMs, minimum one token per run. Runs that never reached the sandbox (infra_error, config_error, denied) are free. A workspace with no tokens does not run actions at all - the run is recorded as denied. The token transaction is attributed to the user who triggered the run; a scheduled run has no such user, so it is attributed to the user who created the action.

maxRunsPerHour counts an action's event-triggered runs - phase, workspace and scheduled - in a sliding one-hour window; manual and test runs are exempt. Once it is exhausted, further event-triggered runs are recorded as denied with the message hourly run limit reached (<limit>) and do not count towards the window. One runaway action never blocks the workspace's other actions.

Permissions ​

Every v3.action.* endpoint requires the caller to be an admin or owner of the action's workspace. Members cannot list, run or edit actions, but their activity changes do fire phase-bound actions, and their joining or leaving fires workspace-bound ones. Scheduled runs fire with no user at all and are attributed to the action's creator. Inside the sandbox the code has no permissions of its own: whatever it does in Hailer, it does as the user whose API key it logs in with.

Hailer Developer Documentation · v1.8.0