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.
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 — FormThe 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.",
}
| Config | inputs[], permissions[], completeExpression? |
| Writes | the fields you defined, under context[stepKey] |
| Advanced by | a 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.
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.
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,
}
| Credentials | SLACK_BOT_TOKEN |
| Preconditions | the bot must be in the channel; private channels must be joined to be listable |
| Writes | slackNotify: { 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 }.
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 }.
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",
}
| Credentials | SAFE_EXECUTOR_PRIVATE_KEY, ETH_RPC_URL, SAFE_API_KEY |
| Preconditions | the 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.
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.