Action handler reference
Every action a Task state can run — parameters, outputs and error codes — grouped by data, communication, integration and control, with the retired ones called out.
A Task state names its handler in Resource and passes configuration in Parameters. Values in Parameters
may use ${$.path} substitution against the state envelope. A handler returns a
result (merged at ResultPath) or a failure with an error code that Retry/Catch can match; a generic failure is
ActionFailed.
Record-triggered flows have a triggering record ($.record); handlers marked record-scoped act on it.
Data#
CREATE_RECORD#
{ "targetCollectionName": "tasks",
"fieldMappings": [ { "targetField": "subject", "value": "Follow up" },
{ "targetField": "invoice", "sourceField": "id" } ] }
value is a literal; sourceField copies a field from the triggering record. Returns the created record.
createdBy/updatedBy are stamped with the flow’s actor.
UPDATE_RECORD#
{ "targetCollectionName": "invoices", "recordIdField": "invoice",
"updates": [ { "field": "status", "value": "PAID" }, { "field": "paid_by", "sourceField": "userId" } ] }
recordIdField names the field on the triggering record that holds the target’s id.
DELETE_RECORD#
{ "targetCollectionName": "drafts", "recordIdField": "draft" }
FIELD_UPDATE (record-scoped)#
{ "updates": [ { "field": "status", "value": "APPROVED" } ] }
QUERY_RECORDS#
{ "targetCollectionName": "orders",
"filters": [ { "field": "customer", "operator": "eq", "value": "${$.record.data.id}" } ],
"sort": "-createdAt", "pageSize": 200,
"aggregations": [ { "function": "SUM", "field": "total", "alias": "total_spent" }, { "function": "COUNT", "alias": "orders" } ] }
Returns { "records": [...], "totalCount": n, "aggregations": { ... } }. Operators are the
filter grammar operators.
SQL_QUERY#
{ "sql": "SELECT id, total FROM orders WHERE customer_id = '${$.record.data.id}'", "maxRows": 1000 }
Runs inside the tenant’s schema (search_path is pinned, so unqualified names cannot reach other tenants).
maxRows defaults to 1000 and is capped at 10 000. SELECT returns { records, rowCount, columns }; DML/DDL
returns { rowsAffected, success }. Available when the worker has a datasource wired (always, in a normal
deployment). Prefer QUERY_RECORDS when it can express the query.
${...} placeholders in sql are bound as query parameters, never pasted into the statement, so a
value from the record or input cannot change the SQL. Write a placeholder where a value goes, either bare
(WHERE total > ${$.input.min}) or as a whole quoted literal ('${$.record.data.id}'). A placeholder cannot
name a table or column, sit inside a longer literal (write '%' || ${$.input.q} || '%' instead of
'%${$.input.q}%'), or be the whole statement; such a step fails with a message saying so.
Communication#
EMAIL_ALERT#
{ "to": "${$.record.data.email}", "subject": "Invoice ${$.record.data.number}", "body": "…" }
or { "to": "…", "templateId": "<email template id>" } to use a tenant email template. Sent through the tenant’s
SMTP configuration (Messaging).
OUTBOUND_MESSAGE#
{ "url": "https://hooks.example.com/kelta", "method": "POST",
"headers": { "X-Source": "kelta" }, "bodyTemplate": "{\"id\": \"{{id}}\", \"status\": \"{{status}}\"}" }
Fire-and-forget webhook; without bodyTemplate the record is sent as JSON.
The URL must be public: a host that resolves to a loopback, private or link-local address is
refused unless the operator allow-lists that range (KELTA_OUTBOUND_ALLOWED_PRIVATE_CIDRS).
GET redirects are followed up to three hops, each checked the same way; other methods do not
follow redirects. The call times out after 60 seconds, and a 4xx/5xx response fails the step.
SEND_NOTIFICATION#
{ "userId": "${$.record.data.ownerId}", "title": "Order approved",
"message": "Order ${$.record.data.number} was approved.", "link": "/app/orders/${$.record.id}", "level": "INFO" }
Writes an in-app notification for one user of the tenant. title (up to 255 characters) and message are
required. userId takes a user id or email and defaults to the user who triggered the flow; a recipient who is not a
user of the tenant fails the step. link is optional (up to 2048 characters). level is INFO (default),
WARNING or ERROR; anything else fails the step.
Output: { "notificationId", "userId", "title", "message", "link", "level" } (link only when set).
The recipient reads their own notifications at GET /api/me/notifications (newest first, page[number] /
page[size], with meta.unreadCount) and marks them read with POST /api/me/notifications/{id}/read or
POST /api/me/notifications/read-all. Nobody else can list or mark them, admins included.
Integration#
CALL_API#
The preferred way to call an external API. Two modes:
{ "mode": "operation", "specId": "<api-spec id>", "operationId": "getCustomer",
"credentialRef": "crm", "path": { "id": "${$.record.data.crm_id}" }, "query": {}, "headers": {},
"responseMapping": { "name": "$.data.name" } }
{ "mode": "raw", "method": "POST", "url": "https://api.example.com/v1/orders",
"credentialRef": "example-api", "requestBody": { "ref": "${$.record.data.id}" },
"idempotency": { "enabled": true, "key": "${$.record.data.id}", "ttlSeconds": 86400 } }
operationmode resolves URL and method from an imported OpenAPI spec.credentialRefnames a vault credential; the handler applies bearer, basic, API-key or OAuth 2 client credentials — secrets never appear in the flow.- Payload fields accept
${$.path}placeholders or=<JSONata expression>. - Error codes:
Api.HttpClientError(4xx),Api.HttpServerError(5xx),Api.Timeout,Api.Refused,Credential.*,Mapper.Failure— catch them individually. - The URL — the raw
urlor the imported spec’s base URL — must be public: a host that resolves to a loopback, private or link-local address is refused (Api.Refused) unless the operator allow-lists that range (KELTA_OUTBOUND_ALLOWED_PRIVATE_CIDRS).GET/HEADredirects are followed up to three hops, each checked the same way. The call times out after 60 seconds (Api.Timeout) and reads at most 1 MB; a larger response keeps a truncatedresponseBodyand noresponseData.
HTTP_CALLOUT (legacy)#
{ "url": "https://api.example.com/endpoint", "method": "GET", "headers": {}, "body": "", "responseVariable": "apiResponse" }
Captures up to 50 KB of response body under responseVariable. Prefer CALL_API.
The URL must be public: a host that resolves to a loopback, private or link-local address is
refused unless the operator allow-lists that range (KELTA_OUTBOUND_ALLOWED_PRIVATE_CIDRS).
GET redirects are followed up to three hops, each checked the same way. The call times out
after 60 seconds, reads at most 1 MB, and a 4xx/5xx response fails the step.
PUBLISH_EVENT#
{ "topic": "order.approved", "eventType": "order.approved",
"dataPayload": { "orderId": "${$.input.orderId}", "total": "${$.input.total}" } }
Publishes a JSON event to kelta.event.<tenantId>.<topic> on the platform’s NATS JetStream (stream KELTA_EVENTS)
for systems outside Kelta. The tenant id in the subject is inserted by the platform; topic is dot-separated
tokens of letters, digits, - and _ — wildcards and empty tokens fail the step. Returns
{ "topic", "subject", "eventType", "messageKey" }. The step fails if no NATS publisher is configured or the publish
is not acknowledged. Subject, envelope fields and consuming: External events.
INVOKE_SCRIPT#
{ "scriptId": "<script id>", "inputPayload": { "threshold": 10 }, "timeoutSeconds": 30 }
or inline "scriptSource": "record.total > input.threshold". Scripts run in a sandboxed JavaScript engine with
record, previousRecord, input and context bindings and return the last expression. Scripts are managed
under Setup → Integration → Scripts.
An active script of type API_ENDPOINT or EVENT_HANDLER can also be run over HTTP — the app’s run script quick
action does this: POST /api/scripts/{id}/execute with { "input": { … }, "context"?: { … } } runs it
synchronously and returns its result. When the script sets requiredPermission, the caller’s profile must hold
that system permission (403 otherwise); without it any API user may run the script.
Control and utility#
DECISION#
{ "condition": "priority = \"High\" AND amount > 1000",
"trueActions": [ { "actionType": "FIELD_UPDATE", "config": { "updates": [ { "field": "sla", "value": "4h" } ] } } ],
"falseActions": [] }
Inline if/else: condition is a formula evaluated against
the state data, and each branch is a list of nested actions. For branching between states a Choice state is
usually clearer.
LOG_MESSAGE#
{ "level": "INFO", "message": "Reached step 3 for ${$.record.data.id}" }
Writes to the run’s step log.
TRIGGER_FLOW#
{ "flowId": "<flow id>", "input": { "orderId": "${$.input.orderId}" } }
Starts another saved flow as a new, asynchronous execution and returns { "flowId", "executionId" } without
waiting for it; the run appears under the target flow’s runs. The target reads input as $.input.<key>, and
$.trigger.type is TRIGGER_FLOW. An unknown flowId fails the step. To wait for another flow’s result, use an
InvokeFlow state instead.
Chains are bounded: each TRIGGER_FLOW hop counts one level (carried in the started run’s initial input as
_triggerDepth, so it also survives a Wait), and a step at depth 10 fails with
… the trigger chain reached MAX_TRIGGER_DEPTH=10 instead of starting another run. A flow that triggers itself,
directly or through other flows, therefore stops after ten hops.
SUBMIT_FOR_APPROVAL (record-scoped)#
{ "processId": "<approval process id>" } // optional — auto-detects the collection's process
Submits the triggering record; see Approval processes.
ANONYMIZE_USER#
{ "userId": "${$.input.userId}" } // required
Erases a user’s personal data and keeps the user’s id; the step fails unless the flow’s user holds MANAGE_USERS.
Result: {"userId", "alreadyAnonymized"}. What is erased and kept: Anonymizing a user.
Rejected at save#
These handlers never did their work. Saving a flow (create or update) with a Task whose Resource is one of them —
at any level, including Parallel branches and Map iterators — returns 400 naming the replacement. A flow saved
before this check still runs, but the step fails with the same message instead of reporting success.
| Handler | Use instead |
|---|---|
DELAY | a Wait state (Seconds, Timestamp, TimestampPath) |
CREATE_TASK | CREATE_RECORD on your tasks collection |
Handlers from modules#
Runtime modules can register additional handlers; they appear in the designer’s palette
under the module’s category and are referenced by their key in Resource.