Each step does one job. Configure steps on the workflow canvas, or send the same parameters object to the workflow API. Parameter names are snake_case.
How steps return data
A step's output is saved under its key, so a step with key find_orders is read with {{ $anythink.steps.find_orders… }}. Where the output lives depends on the step:
| Output shape |
Steps |
Read it with |
A data list |
Read data, Create data, Update data, Upsert data, Run a script, Call an API |
$anythink.steps.<key>.data[0].field |
| Fields at the top level |
Condition, Send an email, Send a push notification, File handler, Integration |
$anythink.steps.<key>.field |
| Nothing |
Delete data |
— |
When a step fails, its error details are saved at the top level of its output, and the workflow follows the step's on failure link if it has one.
Filters in data steps
Read data, Update data and Delete data select records with filter_conditions, a list of conditions that must all match:
[
{ "field": "status", "operator": "eq", "value": "pending" },
{ "field": "created_at", "operator": "lt", "value": "{{ $anythink.now }}" }
]
operator |
Meaning |
eq, neq |
Equals, doesn't equal |
contains, sw, ew |
Contains, starts with, ends with |
gt, gte, lt, lte |
Greater or less than |
is_null, is_not_null |
Empty or not empty (no value) |
value can contain templates.
Warning: Operators other than these are ignored rather than rejected. For a Delete data step, a filter whose operators are all misspelled matches every record. Test delete steps against a copy of your data first.
Note: Values are added to the query as they are. Avoid values containing & in filters until this is handled.
Row-level security options
Steps run as the workflow worker, so records they create have no owner and aren't readable by users on entities with row-level security. Create data, Update data and Upsert data can grant access as part of the write:
| Parameter |
Default |
What it does |
auto_set_rls |
false |
Grant the users in rls_user_field read and write access |
rls_user_field |
user |
Payload field holding a user id, a comma-separated list of ids, or a list |
auto_set_rls_groups |
false |
Grant the groups in rls_group_field read and write access |
rls_group_field |
group |
Payload field holding group ids |
auto_set_rls_user_groups |
false |
Grant access to the groups the rls_user_field user belongs to |
rls_user_group_mode |
all |
all of that user's groups, or selected to use rls_user_group_id |
rls_user_group_id |
— |
Group id for selected mode. Templated. |
rls_user_groups_readonly |
true |
Group members can read but not edit |
rls_user_group_grant |
false |
Also write a group grant, so every member of the group can read the record |
rls_user_group_field |
— |
Field to stamp with the user's group id, when the user belongs to exactly one group |
rls_mode |
add |
Update data only: add, remove or set the grants |
A payload can also carry grants directly:
{
"title": "Weekly check-in",
"user_id": 21,
"_rls": { "mode": "add", "users": [{ "user_id": 21, "readonly": false }] }
}
If a field named for grants is missing or can't be read, the step logs a warning and writes the record without that grant.
Limit: Update data ignores the row-level security options when it updates by ids. Use filters, or an _rls payload, to change grants.
Read data
Reads records from an entity.
| Parameter |
Required |
Default |
Notes |
entity |
yes |
— |
Entity name. Note: entity, not entity_name. |
filter_conditions |
no |
none |
See Filters in data steps |
limit |
no |
5000 |
Maximum records. Fetched 1,000 at a time. |
fields |
no |
all |
Comma-separated field names |
sort_by |
no |
— |
Field name |
sort_direction |
no |
— |
asc or desc |
Limit: Omitting limit currently fails validation with "Limit must be greater than or equal to 0" instead of falling back to 5000. Set limit explicitly until this is fixed.
Limit: anythink workflows step-add and step-update both silently rewrite a Read data step's entity parameter to entity_name, which then fails with "Entity is required". Neither CLI command can set a working Read data step yet — set parameters with a direct PUT /org/{orgId}/workflows/{workflowId}/steps/{stepId} call instead, until this is fixed.
Returns data: the list of records.
{
"entity": "orders",
"filter_conditions": [{ "field": "status", "operator": "eq", "value": "pending" }],
"sort_by": "created_at",
"sort_direction": "asc",
"limit": 100
}
Reads ignore row-level security. Filter to the records the workflow should act on.
Create data
Creates one record, or one record per item in a list.
| Parameter |
Required |
Default |
Notes |
entity_name |
yes |
— |
|
payload |
yes |
— |
A JSON object, or a list of objects. Templated. |
loop_over |
no |
— |
A template resolving to a list, such as {{ $anythink.steps.find_orders.data }}. The payload is filled in once per item, and the item's fields are available without a prefix: {{ id }}, {{ email }}. |
prevent_duplicates |
no |
false |
Skip an item when a record already matches all of its non-empty fields |
| Row-level security |
no |
|
See Row-level security options |
Returns data: the created records, or an empty list if every item was a duplicate. Fails when loop_over doesn't resolve to a list or a create fails.
{
"entity_name": "order_reviews",
"loop_over": "{{ $anythink.steps.large_orders.data }}",
"payload": "{ \"order_id\": \"{{ id }}\", \"note\": \"Review orders over £100\" }",
"prevent_duplicates": true
}
Update data
Updates records by id or by filter.
| Parameter |
Required |
Notes |
entity_name |
yes |
|
payload |
yes |
JSON object. Templated. With filters, it's filled in once per matched record, and that record's fields are available without a prefix. |
ids |
one of ids or filters |
Comma-separated ids, templated. Takes priority over filters. |
filter_conditions |
one of ids or filters |
|
| Row-level security |
no |
Includes rls_mode. Filters only. |
Returns data: the payloads that were sent (not the saved records). Fails when ids resolves to no numbers, or when neither ids nor filters are set.
{
"entity_name": "orders",
"ids": "{{ $anythink.trigger.id }}",
"payload": "{ \"status\": \"reviewed\", \"reviewed_at\": \"{{ $anythink.now }}\" }"
}
Upsert data
For each item, finds a matching record by key fields, then updates it, leaves it, or creates a new one. Use it for imports and enrichment, where the same data may arrive more than once.
| Parameter |
Required |
Default |
Notes |
entity_name |
yes |
— |
|
payload |
yes |
— |
JSON object or list. Templated. |
match_on |
yes |
— |
List of fields that identify a record, such as ["external_id"] |
on_match |
no |
Ignore |
Ignore returns the existing record; Update writes the payload to it |
update_fields |
no |
all fields |
On Update, only write these fields |
loop_over |
no |
— |
As in Create data |
| Row-level security |
no |
|
Applied to created and updated records |
Returns data: one record per item, in the order of the input. Fails when an item is missing a match_on field.
When more than one record matches, the oldest (lowest id) is used and a warning is logged. Items are processed one at a time; if one fails, earlier items stay written.
{
"entity_name": "customers",
"loop_over": "{{ $anythink.steps.fetch_customers.data }}",
"payload": "{ \"external_id\": \"{{ id }}\", \"email\": \"{{ email }}\", \"name\": \"{{ name }}\" }",
"match_on": ["external_id"],
"on_match": "Update",
"update_fields": ["email", "name"]
}
Delete data
Deletes every record matching the filter.
| Parameter |
Required |
Notes |
entity_name |
yes |
|
filter_conditions |
yes |
At least one condition |
Returns nothing, so later steps can't reference it. All matching ids are collected first and then deleted.
{
"entity_name": "sessions",
"filter_conditions": [{ "field": "expires_at", "operator": "lt", "value": "{{ $anythink.now }}" }]
}
Condition
Branches the workflow. When the conditions hold, the workflow follows on success; otherwise it follows on failure.
| Parameter |
Required |
Default |
Notes |
filter_conditions |
yes |
— |
field is a template path, such as $anythink.steps.find_orders.data[0].total; value can be templated |
logical_operator |
yes |
AND |
AND or OR |
Text compares without regard to case. gt, gte, lt and lte compare as numbers when both sides are numbers.
Returns condition_result, logical_operator and evaluation_details, which lists each comparison with its actual value — useful when a branch goes the wrong way.
{
"logical_operator": "AND",
"filter_conditions": [
{ "field": "$anythink.trigger.data.total", "operator": "gte", "value": "100" }
]
}
Note: A false result with no on failure link ends the job as Failed. When "nothing to do" is normal, link the false branch to a harmless step.
Limit: is_null and is_not_null don't work reliably in conditions yet, because missing values are compared as text. Compare with eq and an empty value, or test for emptiness in Run a script.
Run a script
Runs JavaScript for logic that the other steps can't express: reshaping data, calculations, building prompts, parsing responses.
| Parameter |
Required |
Notes |
script |
yes |
The body of a function. Use return to output data. |
The script can read a $anythink object:
| Property |
Contents |
$anythink.trigger |
id, entity_name and data |
$anythink.steps |
Every earlier step's output, by key |
$anythink.workflow |
id, tenant_id, name, description, created_at, updated_at |
$anythink.job |
id and the current step |
console.log, info, warn, error and debug write to the step's log. isArray, isObject and deepClone are available as helpers.
| Script returns |
Output |
| An object |
data is a list containing that object |
| A list |
data is that list |
| Nothing |
No output |
| A number, text or boolean |
The step fails — return an object instead |
| Throws an error |
The step fails and follows on failure |
var orders = $anythink.steps.find_orders.data;
var total = orders.reduce(function (sum, o) { return sum + o.total; }, 0);
return { count: orders.length, total: total, over_budget: total > 5000 };
Scripts run on the Jint JavaScript interpreter with a 30-second timeout, 64 MB of memory and 100,000 statements. They have no network or file access, templates aren't applied inside scripts, and secrets aren't available — use Call an API or Integration for anything external.
Call an API
Sends an HTTP request.
| Parameter |
Required |
Notes |
url |
yes |
Templated. Template values aren't URL-encoded, so encode them yourself. |
method |
yes |
GET, POST, PUT or DELETE, in capitals |
headers |
no |
Object of header names to values. Values are templated. |
body |
no |
Templated. Sent as application/json. |
results_path |
no |
Dot path into the response to keep, such as data.items |
Returns data: the parsed JSON response, or the part at results_path. Fails on a non-2xx status, with status_code and response in the output.
{
"url": "https://api.example.com/v1/customers?updated_since={{ $anythink.steps.last_sync.data[0].synced_at }}",
"method": "GET",
"headers": { "Authorization": "Bearer {{ $anythink.secrets.example_api_key }}" },
"results_path": "customers"
}
Note: An empty response stores no output. A response that isn't JSON, or a results_path that isn't found, succeeds with data set to {} — check the result in a Condition when it matters.
Note: Put credentials in headers, not in the URL. Request URLs are written to the step log.
Send an email
Sends one of your project's email templates. See Email templates.
| Parameter |
Required |
Default |
Notes |
to |
yes |
— |
Templated; must resolve to an address |
from |
no |
project default |
Used only when it's on one of your verified sending domains |
template_type |
yes |
— |
The template's key |
payload |
no |
{} |
JSON object of template variables. Templated. |
attachments |
no |
— |
Up to 10 { "file_id", "disposition", "content_id" } entries |
Returns to, template_type, variables and attachment_count. Success means the email was queued for sending, not that it was delivered.
{
"to": "{{ $anythink.trigger.data.email }}",
"template_type": "welcome",
"payload": "{ \"first_name\": \"{{ $anythink.trigger.data.first_name }}\" }"
}
Send a push notification
Sends a push notification through your configured providers and waits up to 30 seconds for the delivery result.
| Parameter |
Required |
Notes |
template_type |
no |
A push template that supplies any content you leave empty |
title, body |
yes, unless a template is set |
Templated |
image_url, click_action |
no |
Templated |
category |
no |
An action-button category registered for your project |
buttons |
no |
[{ "id", "label", "click_action" }] |
data |
no |
Object of extra values delivered with the notification |
audience.kind |
no |
user (default), users, entity or all |
audience.user_id |
for user |
Defaults to the trigger data's user_id |
audience.user_ids |
for users |
One notification to every user in the list |
audience.owner_entity_name, audience.owner_entity_id |
for entity |
Users with access to that record. The id defaults to the trigger id. |
audience.provider |
no |
fcm, web_push or apns |
Send once per row. When the title, body or another content field refers to $anythink.steps.<key>.data[*], the step sends one notification per item in that step's data, with the item's fields available by name. Up to 10 send at once.
{
"title": "{{ $anythink.steps.due_checkins.data[*].first_name }}, time for your check-in",
"body": "It takes two minutes.",
"audience": { "kind": "user", "user_id": "{{ $anythink.steps.due_checkins.data[*].user_id }}" }
}
Returns send_log_id, delivery_status, delivered, failed and unregistered; per-row sends return iterations and totals. The step fails if delivery fails — for per-row sends, only if every send fails.
File handler
Moves files in and out of records.
| Parameter |
Required |
Default |
Notes |
operation |
yes |
— |
FetchAndLink, LinkExisting or Detach |
entity_name, record_id, field_name |
yes |
— |
The record and file field to change. Templated. |
source_url |
for FetchAndLink |
— |
Public http(s) URL to download |
file_name, folder_id |
no |
from the URL |
Templated |
is_public |
no |
false |
|
link_mode |
no |
set |
set replaces the field's files; add appends |
overwrite_if_present |
no |
false |
With set, replace a file that's already there |
skip_if_empty_source_url |
no |
false |
Succeed without doing anything when source_url is empty |
existing_file_id |
for LinkExisting |
— |
Templated |
Downloads are limited to 50 MB and 30 seconds, and private or local network addresses are refused.
Returns file_id, url, mime_type, size_bytes and skipped, which is true when an existing file was kept.
{
"operation": "FetchAndLink",
"entity_name": "products",
"record_id": "{{ $anythink.trigger.id }}",
"field_name": "image",
"source_url": "{{ $anythink.trigger.data.image_url }}",
"skip_if_empty_source_url": true
}
Integration
Calls a connected service with your project's or a user's credentials. Connect services in Settings › Integrations.
| Parameter |
Required |
Default |
Notes |
provider |
yes |
— |
See the table below |
operation |
yes |
— |
|
credential_source |
no |
system |
system (project connection), current_user, connection or entity_field |
connection_id |
for connection |
— |
Templated |
credential_field_path |
for entity_field |
— |
Template resolving to stored credentials |
inputs |
no |
{} |
Operation inputs. Text values are templated. |
| Provider |
Operations |
claude, openai, grok |
generate-text, analyse-image, analyse-document, summarise, translate; OpenAI adds generate-image |
slack |
send-message, send-direct-message |
github |
list-commits, list-pull-requests, list-releases, list-issues, get-file-contents, get-repo-tree, list-workflow-runs (read-only) |
google-calendar |
create-event, list-events, get-event, update-event, delete-event |
linkedin |
create-post |
twitter |
create-tweet |
Returns the service's result at the top level. AI text operations return text, model, input_tokens and output_tokens, read with {{ $anythink.steps.<key>.text }}.
{
"provider": "claude",
"operation": "generate-text",
"credential_source": "system",
"inputs": {
"model": "claude-opus-4-8",
"max_tokens": "2000",
"system_prompt": "Summarise customer feedback in two sentences.",
"prompt": "{{ $anythink.trigger.data.feedback }}"
}
}
Limit: Claude models that start their reply with a thinking block, including Claude Opus 5, currently return empty text. Use claude-opus-4-8 until support lands. With no credentials connected, the step fails and tells you which service to connect.
Next steps