Alokai
For Developers

Workflows

Multi-step server-side jobs with review gates, run by hand or from a webhook.

A kind: 'workflows' view lists jobs that run on the server in steps - with live progress, logs, and optional review gates where a person approves or rejects before the job continues. A run starts in one of two ways: a person clicks Run workflow in the panel, or an external system calls the workflow's webhook.

The Workflows screen in the panel: each workflow listed with its description, step count, and how it's triggered

Defining workflows

Two names, two things. The WorkflowsView is a view like any other - it goes into the views of a feature. Each element of its workflows array is one WorkflowDefinition, and that's what the table describes.

import { defineFeature } from '@alokai/connect-admin/server';

export const pricing = defineFeature({
  enabled: true,
  id: 'pricing',
  name: 'Pricing',
  views: [
    {
      id: 'workflows',
      kind: 'workflows',
      label: 'Workflows',
      workflows: [
        {
          id: 'import-prices',
          label: 'Import price list',
          description: 'Fetch, summarize, apply after approval.',
          concurrency: 'single',
          inputs: { fields: [{ kind: 'text', label: 'Source URL', name: 'url', required: true }] },
          steps: [ /* the Steps chapter below fills this in */ ],
        },
      ],
    },
  ],
});

Like every feature, pricing then has to be listed in the module's configuration.features - see Registering features.

PropertyWhat it does
idUnique within the view, and stable - run history and webhooks key off it.
label, descriptionWhat the panel shows.
stepsThe step list, executed in order.
inputsAn optional small form the panel shows before starting; values arrive in each step as workflowContext.input. Only the plain field kinds - see Run inputs.
concurrency'parallel' (default) or 'single' - see below.
hiddenNot listed in the panel and can't be started by hand; pair it with a webhook for machine-triggered jobs.
webhookThe HTTP trigger - see below.

Static workflow lists are validated at boot, and a bad definition fails loudly. The check catches: no steps, a duplicate workflow id inside one view, duplicate step or decision ids, a run input using a kind that isn't allowed, a review step nested inside a decision branch's steps, a hidden workflow with a review step, and a production webhook with no secret.

Run inputs

inputs is the small box the panel shows before Run workflow. It takes a small, fixed set of field kinds - roughly what GitHub Actions offers, plus picking several options from a fixed list:

AllowedNot allowed
text, textarea, numberobject, map, array
boolean, togglestringList, tagList, sentenceRule
select, multiSelect, multiSelectDropdown

A wrong kind is a TypeScript error where you write it. Configs written by hand and workflows built by a resolver skip that check, so the same rule is enforced again while the app is up.

Anything richer than this belongs in a form view that the workflow reads with getFormValues - which is also how a form can drive which workflows exist in the first place.

Steps

steps is the heart of a WorkflowDefinition: an ordered list executed top to bottom, where each entry is either a task (code) or a review (a person).

Task steps

A task step is server-side code. Here's the first entry of the steps array from the definition above:

import type { CommerceEndpoints } from '@/types';

// inside `steps: [...]` of the import-prices workflow
{
  id: 'fetch',
  kind: 'task',
  label: 'Fetch and check the price list',
  timeoutMs: 60_000, // default 15 minutes
  run: async (context, wf) => {
    wf.log.info('Fetching…', { url: wf.input.url });
    const rows = await fetchRows(String(wf.input.url), { signal: wf.signal });

    // `context` is the same middleware context your API methods get -
    // here it reaches the SAP Commerce Cloud integration:
    const { api: commerce } = await context.getApiClient<CommerceEndpoints>('commerce');
    const { data: product } = await commerce.getProduct({ productCode: rows[0].sku });
    wf.log.info(`Prices arrive for products like ${product.name}`);

    return { count: rows.length }; // becomes wf.outputs['fetch']
  },
},

(fetchRows stands for your own code.) That context is the point of the whole feature: a step can call anything your server middleware can. getApiClient reaches every integration registered in middleware.config.ts - the commerce backend above, a CMS, search, your own extensions. The one thing a step must never touch is context.res: steps run detached, after the HTTP response that started them has already ended.

A task can also carry condition - a callback that skips the step when it returns false (a throw fails the run, only false skips).

By default the run drawer shows a task's result as raw JSON. Add renderOutput to present it as simple HTML you render server-side - a readable table instead of an object dump - sandboxed the same way as a review's htmlView below.

What every callback receives

Every step callback - run, condition, present, renderOutput - gets two arguments: the middleware context above, and this workflowContext:

FieldWhat it is
inputThe values from inputs (or the webhook's mapped payload).
logdebug/info/warn/error(message, data?) - this is what the operator watches live.
outputsResults of earlier steps, keyed by step id. Typed Record<string, unknown>, so cast what you read - as the sample above does.
signalAn AbortSignal that fires on cancel or timeout. Pass it to your I/O - a step that ignores it can't be cancelled.
trigger'manual' or 'webhook'.
runId, workflowIdIdentifiers, useful in logs.

A throwing step fails the run; so does exceeding the step timeout. A step's return value is persisted, and a value over 256 KB fails the step rather than being truncated - return identifiers and re-fetch bulk data instead of returning it.

Review steps

A review step parks the run and waits for a person. present builds what they see; decisions are the buttons they choose from. A later entry of the same steps array:

// inside `steps: [...]`, after the tasks that prepared the change
{
  id: 'approve',
  kind: 'review',
  label: 'Approve the change',
  present: (context, wf) => ({
    title: 'Apply these prices?',
    summary: `${(wf.outputs.fetch as { count: number }).count} rows will change.`,
    data: wf.outputs.fetch,
    htmlView: { html: renderPriceTable(wf.outputs.fetch) },
  }),
  decisions: [
    { id: 'apply', label: 'Apply', variant: 'primary' },
    { id: 'stop', label: 'Reject', outcome: 'stop', variant: 'danger' },
  ],
},

Here's a run parked at a review gate - the presentation built by present, the Preview / JSON switch, and the decision buttons under it:

A run waiting at a review gate: the reviewer sees the presentation the step built, can flip between the rendered preview and the raw JSON, and decides with the buttons below

  • Omitting decisions gives a default Approve/Reject pair.
  • outcome: 'stop' ends the run as rejected - a decision, not a failure.
  • A decision can carry its own steps: task steps that run only when that decision is taken, which is how you branch.
  • htmlView: { html } is how you show reviewers something nicer than JSON - simple HTML you render server-side from the step outputs, like renderPriceTable above. The drawer renders it inside a sandboxed frame, so scripts inside it never run - even if platform or LLM content includes a <script> tag. Nothing in the frame can reach the panel, and relative URLs won't load.
  • data is shown as raw JSON - with a Preview / JSON switch when a rich view exists too. Omitting present entirely shows the previous step's output under the step's label.
  • A throwing present fails the run.
  • A review step's condition defaults to skipping the review on webhook runs - an unattended run has nobody to ask. Reviews wait at most 24 hours, then the run ends as expired and has to be started again - so design steps to be safe to repeat.

Run statuses

StatusTerminal?Meaning
queuednoWaiting for an executor slot (the concurrency cap).
runningnoExecuting.
awaiting-reviewnoParked at a review gate; the drawer keeps polling.
succeededyesAll steps done.
failedyesA step threw, timed out, or exceeded the output cap.
rejectedyesA review decision with outcome: 'stop'.
cancelledyesA person cancelled the run.
expiredyesA review waited 24 h, or a newer manual run superseded it.
interruptedyesThe server working on it - or holding it in the queue - died; housekeeping marks it.
skippedyesA webhook fired while a single workflow was busy - recorded instead of run.

Concurrency

The default is 'parallel' - any number of simultaneous runs. With concurrency: 'single':

Already going onA person clicks Run workflowThe webhook fires
Nothingstartsstarts
Running or queuedrefused with 409refused - a skipped run is recorded
Waiting for reviewasks to confirm, then ends that review as expired and startsrefused - a skipped run is recorded

Only a person may end a waiting review. A webhook never supersedes one - it records a skipped run instead, and the delivery isn't retried.

Triggering from a webhook

The webhook is how other systems start a run - and how timed jobs work, too: anything that already runs on a clock (CI, a cloud scheduler) calls the webhook. webhook sits on the WorkflowDefinition, next to steps:

// one entry of the view's `workflows` array
{
  id: 'import-prices',
  label: 'Import price list',
  steps: [ /* as above */ ],
  webhook: {
    enabled: true,   // false switches the webhook off without removing it - callers get 404
    secret: process.env.PRICE_IMPORT_WEBHOOK_SECRET!,
    debounceMs: 30_000,
    mapInput: (context, payload) => ({ url: (payload as { url: string }).url }),
  },
},

Callers POST to the one shared endpoint, naming the workflow in the body:

curl -X POST https://<admin-host>/connect-admin/devFeatureEditorWorkflowWebhook \
  -H 'content-type: application/json' \
  -H 'x-connect-admin-webhook-secret: <secret>' \
  -d '{ "featureId": "pricing", "viewId": "workflows", "workflowId": "import-prices", "payload": { "url": "…" } }'
ResponseMeaning
202 { runId }The run was accepted and queued - it starts once an executor slot frees up. The response isn't the result; the run continues server-side.
202 { debounced: true }Accepted into a debounce window: after debounceMs of quiet, one run starts with the latest payload.
403Missing or wrong secret. A webhook with no secret configured is also refused in production.
404Unknown workflow, or its webhook is disabled.
409Refused by single concurrency (a skipped run is recorded) - or two deliveries collided for a moment, in which case nothing is recorded and a retry is safe.
422mapInput threw - the payload didn't map to valid inputs.

The endpoint path moves with the integration key, and it lives on the Connect Admin app - callers use the panel's address, not the storefront's.

Retention and caps

These are fixed constants, not environment variables:

  • Review expiry: 24 hours, for every workflow.
  • Default step timeout: 15 minutes (timeoutMs overrides per step).
  • Run logs: 256 KB per run - past the budget the log ends with one warning line, keeping the start of the run. Step outputs: 256 KB, over-cap fails the step.
  • History: the last 25 terminal manual runs + 25 terminal webhook runs per workflow, with a 7-day backstop. The 10 newest failed and interrupted runs always survive that count, and runs parked at a review gate are never pruned by count.

The only env-tunable knob is CONNECT_ADMIN_WORKFLOWS_MAX_CONCURRENT_RUNS (default 10) - how many runs execute at once per process; the rest wait as queued.

Dynamic workflows

workflows can be a resolver instead of a static array:

workflows: async (context) => {
  const config = await getFormValues(context, { viewId: 'workflow-config' });
  const campaigns = (config?.campaigns ?? []) as Campaign[];
  return campaigns.filter(c => c.enabled).map(c => ({
    id: `campaign-${c.id}`,          // STABLE across re-resolutions
    label: `Refresh: ${c.name}`,
    steps: buildSteps(c),
  }));
},

This is how a form view can drive which workflows exist. Three things to know:

  • Ids must be stable. A resolver that changes ids orphans run history and breaks webhook callers.
  • Every resolution is validated. A resolver builds its workflows after boot, so there's no boot left to fail at; the same validator runs at every resolution instead. A workflow that doesn't pass is logged and left out - it doesn't appear in the list, and it can't be started, resumed, or fired by a webhook. Watch the middleware log for ignoring workflow "<id>" - invalid configuration, because from the panel a dropped workflow just looks missing.
  • One rule is skipped on this path: the "a webhook needs a secret in production" check. The webhook route already refuses those with a 403 that says why, and dropping the definition would answer 404 and tell the caller nothing.

On this page