Step types

All 15 step types: what they take, what they write, and how they fail.

These are app-owned authoring types, not Platform opcodes. Provider/configuration examples compile to closed syntax; the new inline-template example illustrates that boundary.

For the model behind them — the chain, the three gates on a human step, why the categories do not predict behavior — see steps.

Categories match STEP_TYPE_META in applications/main/src/operator-ui/metadata.ts, which the editor palette and the runner both read.


Failure contracts

Known failures have library-specific soft-warning, terminal-failure or explicit recovery/backoff policies. An uncertain post-dispatch outcome instead holds for reconciliation; it is not blindly retried. See deployment bindings.

All non-human activity bodies execute through the generic worker. Source type names do not select native runtime handlers. The old per-handler exception table no longer describes the architecture.


input — Form

The only step that waits for a person.

{
  key: "input_description",
  type: "input",
  title: "Provide Description",
  permissions: [],
  nextStepKey: "review_description",
  inputs: [
    { key: "_view_0", type: "string", title: "Feedback from OEA",
      readOnly: true, defaultValue: "${review_description.description_review}" },
    { key: "description", type: "string", title: "Describe why you need a new Halo" },
  ],
  confirmationMessage: "Thank you. A member of the OEA team will be in touch.",
}
Configinputs[], permissions[], completeExpression?
Writesthe fields you defined, under context[stepKey]
Advanced bya user completing the step

permissions[] gates who may edit. completeExpression gates whether the step may be finished — a separate question, with hasPermission("name") in scope. Individual fields may carry their own permissions[]; writes are filtered to permitted keys.

See field types.


Logic

No credentials, no network.

condition — Decision

{
  key: "review_description_condition",
  type: "condition",
  title: "Review Description Condition",
  expression: "review_description.description_review_ok === true",
  thenStepKey: "input_risk_model_availability",
  elseStepKey: "input_description",     // ← backwards = rework loop
  nextStepKey: null,
}

nextStepKey is ignored. Pointing elseStepKey at an earlier step builds a loop; each pass appends another ProcessStep.

File: entities/template/condititon-template-step.ts (misspelled in the repo).

automatic — Set context

{
  key: "derive_total",
  type: "automatic",
  title: "Compute total",
  contextKey: "total",
  expression: "amounts.a + amounts.b",
  nextStepKey: "review",
}

Writes the evaluated result to contextKey. Not used by any shipped template.

wait — Wait

{
  key: "wait_until_monday_8pm_utc",
  type: "wait",
  title: "Wait until Monday 20:00 UTC",
  waitUntilExpression: "nextUtcWeekdayTime(1, 20, 0)",
  nextStepKey: "execute_safe",
  confirmationMessage: "Monday 20:00 UTC reached — proceeding to auto-execute.",
}

Evaluated once on arrival and stored as context[stepKey].waitUntil. The worker skips the step each tick until that time passes.


Notification

Both use the soft contract for logical failures — a bad channel, a missing token — recording the outcome and letting the run continue.

The outcome is stored one level in, under the runner's own key:

context["notify"] = {
  slackNotify: { at, ok, error, channelId, resolvedChannelId,
                 resolvedMentionUserIds, skippedNotInChannel, … },
}

Read it with stepAutomationOutcomes() from lib/automation-outcome.ts rather than reaching into the bucket — context["notify"].ok is always undefined, which is how a failed send once rendered as a green tick.

A transport failure is different: the fetch is unguarded, so a network error throws and the step is retried instead.

slack_notify

{
  key: "notify",
  type: "slack_notify",
  title: "Notify team",
  channelId: "soterlabs-agent-onboarding",
  mentionUsers: ["jamilya@soterlabs.com", "filip@soterlabs.com"],
  messageExpression: '"Agent onboarding intake form completed."',
  nextStepKey: null,
}
CredentialsSLACK_BOT_TOKEN
Preconditionsthe bot must be in the channel; private channels must be joined to be listable
WritesslackNotify: { ok, error?, channelId, resolvedMentionUserIds, skippedNotInChannel }

channelId takes a C…/G… id or a channel name with or without #. Mentions accept emails (resolved via users.lookupByEmail) or U… ids — only users already in the channel are mentioned. mentionUsersExpression overrides the static list, which is how you notify a signer chosen on an earlier step.

telegram_notify

{
  key: "notify_payout_complete_telegram",
  type: "telegram_notify",
  title: "Notify payout complete (Telegram)",
  chatId: IB_PAYOUT_COMPLETE_TELEGRAM_CHAT_ID,
  parseMode: "HTML",
  messageExpression: '"Weekly IB payout executed.\\n\\nTx: <code>" + txHash + "</code>"',
  nextStepKey: null,
}

Credentials: TELEGRAM_BOT_TOKEN. Writes telegramNotify: { ok, error?, chatId, messageId }.


Automation

All fail the run except request, which throws.

request — AI request

{
  key: "agent_recommendation",
  type: "request",
  requestType: "agent",
  title: "LLM Recommendation",
  prompt: "You are assisting with new Halo requests… Write a short recommendation.",
  nextStepKey: null,
}

Credentials: GEMINI_API_KEY. Current context is passed to the agent alongside prompt.

Known request failures follow explicit compiler recovery policy. Missing capabilities are explicit configuration failures; post-dispatch transport uncertainty is held rather than retried.

template — Closed composition

{
  key: "calculation", type: "template", title: "Calculate",
  body: { tag: "pure", expression: { tag: "literal", value: { value: 1 } } },
  contextMode: "merge", errorPolicy: "fail-process", nextStepKey: null,
}

The complete body is inspectable syntax. Imported library builders may construct it at authoring time; no runtime JavaScript source or native callback is admitted. Historical script nodes require explicit offline conversion to closed definitions. Unknown source is rejected.

dune — Dune Query Read

{
  key: "fetch_rates",
  type: "dune",
  title: "Read latest rates",
  queryId: 3421567,          // from dune.com/queries/{id}
  nextStepKey: "compute",
}

Credentials: DUNE_API_KEY. Writes context[stepKey] = result.rows, all pages merged — an array, not an object, unlike every other type.

sheets_append_row

{
  key: "log_payment",
  type: "sheets_append_row",
  title: "Append to payments sheet",
  spreadsheetId: "1AbC…",    // from /d/{id}/ in the URL
  sheetGid: 0,               // from gid= in the URL
  valueExpressions: ["todayIsoDate()", "payment.amount", "payment.recipient"],
  nextStepKey: null,
}

Credentials: GOOGLE_SERVICE_ACCOUNT_JSON (or _EMAIL + _PRIVATE_KEY). The service account needs edit access to the sheet. Use sheetName when you do not have the gid.

notion_create_database_item

{
  key: "record_in_notion",
  type: "notion_create_database_item",
  title: "Record topup in Notion",
  databaseId: CURVE_TOPUP_NOTION_DATABASE_ID,
  items: [{
    properties: {
      Payment: { type: "title", expression: 'todayIsoDate() + " — Skybase Curve IB"' },
    },
  }],
  nextStepKey: null,
}

Credentials: NOTION_API_KEY. The integration must be shared with the database.

Each entry in items[] creates one page. Property types: title, rich_text, number, select, multi_select, date, url, checkbox, email, phone_number. Rows from itemsExpression are appended after the static items and require propertyTypes.

database_read

{
  key: "load_vendors",
  type: "database_read",
  title: "Load vendors",
  collectionKey: "vendors",
  filterExpression: '{ active: true }',  // optional; omit / {} = all
  limit: 100,                            // optional; default 100
  nextStepKey: "next",
}

Reads from the document database. Writes context[stepKey] = { documents: [{ _id, …fields }], count }. Fails the run if the collection is missing or the filter expression is invalid.

database_write

{
  key: "save_vendor",
  type: "database_write",
  title: "Upsert vendor",
  collectionKey: "vendors",
  mode: "upsert",                        // insert | update | upsert
  documentExpression: "{ name: form.name, active: true }",
  documentIdExpression: "form.vendor_id", // optional
  filterExpression: '{ name: form.name }', // optional; required (with/instead of id) for update/upsert
  nextStepKey: null,
}

Writes to the document database. When both id and filter are set, id wins (single document). Filter alone updates all matches. update fails if nothing matches; upsert inserts in that case. Result: { mode, matched, upserted, documents }.


Integrations

safe_execute

{
  key: "execute_safe",
  type: "safe_execute",
  title: "Auto-execute Safe transaction",
  safeAddress: SKYBASE_SAFE_ADDRESS,
  safeTxHashExpression: "execution.curve_safe_tx_hash",
  chainId: 1,
  privateKeyEnv: "SAFE_EXECUTOR_PRIVATE_KEY",
  rpcUrlEnv: "ETH_RPC_URL",
  nextStepKey: "record_in_notion",
}
CredentialsSAFE_EXECUTOR_PRIVATE_KEY, ETH_RPC_URL, SAFE_API_KEY
Preconditionsthe Safe tx must already be signed to threshold; a Transaction Service URL must exist for chainId

Fetches a pre-signed multisig transaction from the Safe Transaction Service and broadcasts execTransaction. Any EOA may execute once threshold is met — the executor key pays gas, it is not a signer.

privateKeyEnv and rpcUrlEnv name which env var to read, so one deployment can hold several executor keys.

Credentials and RPC/service endpoints are deployment bindings. Missing capability configuration is diagnosed when used; it must not alter the immutable compiled definition. See deployment bindings.


Known gaps

The public Process status still preserves running/completed plus error. enabledExpression remains round-tripped metadata, not an execution gate. Stronger reconciliation/retry policy and production cutover verification remain explicit work; see current status.