These docs cover the S6S workflow engine — the developer playground layer underneath the product. For the main AI Visibility product (brand monitoring, the unified Report, fix actions), start at /docs/ai-visibility.
Expressions
Reference data between workflow steps using double-brace template expressions.
Syntax
Use double curly braces {{expression}} inside any config field value to reference data from previous steps, the trigger, or the current run.
{
"ref": "format",
"action": "transform",
"config": {
"template": {
"greeting": "Hello, {{trigger.output.body.name}}!",
"previous_result": "{{fetch_data.output.body.items[0].title}}"
}
}
}Expressions are resolved at runtime just before the step executes. Every config value that is a string (or a string nested in an object or array) is scanned for double-brace patterns and resolved.
Available Contexts
| Expression | Description |
|---|---|
| {{trigger.output.body}} | Webhook request body payload (object or string) |
| {{trigger.output.headers}} | Webhook request headers |
| {{trigger.output.query}} | Webhook query string parameters |
| {{trigger.output.method}} | Webhook HTTP method |
| {{trigger.output.fired_at}} | Schedule trigger timestamp (schedule triggers only) |
| {{steps.<ref>.output.*}} | Output from a previous step, accessed by its ref name |
| {{run.id}} | Unique ID of the current run |
| {{run.startedAt}} | ISO timestamp when the run started |
Note on step output access: You can reference step outputs as either {{step_ref.output.field}} or {{steps.step_ref.output.field}}. Both forms work. The shorter form ({{step_ref.output.field}}) is more common in recipes.
Output Shapes by Action Type
Each action type produces a different output shape. Knowing the shape tells you which fields to reference in expressions.
| Action | Output Shape |
|---|---|
| transform | The evaluated template object. Whatever keys you define in template become output fields. |
| http | { status, statusCode, statusText, body, headers } — HTTP response envelope. API data is inside .body. |
| code | Whatever the JavaScript expression returns. Objects are returned as-is; primitives are wrapped in { value: ... }. |
| provider.action | Same as http — compiled to HTTP at deploy time. { status, statusCode, statusText, body, headers }. Always access via .body. |
| slack | { ok, ts } |
| telegram | { messageId, chatId, action } |
| discord | { messageId, channelId } |
| ai | { text, json?, model, promptTokens, completionTokens } — json is populated when responseFormat is "json". |
| condition | { conditionResult, resolvedValue, operator, compareValue } |
{ messageId, accepted } | |
| gmail | { messages, resultCount } (search), { message } (read), { messageId, threadId } (send) |
| google_sheets | { rowCount, values } (read) or { updatedRows } (write) |
| rss | { feedTitle, itemCount, items } |
| filter | { result } |
| loop | { items, count } |
| database | { rows, rowCount } |
| execute_workflow | { runId, status, workflowSlug, result } |
Nested Access
Use dot notation for nested objects and bracket notation for arrays:
| Expression | What it accesses |
|---|---|
| {{trigger.output.body.user.email}} | Nested object field from webhook body |
| {{fetch.output.body.items[0].name}} | First element of an array, then a field on that element |
| {{fetch.output.body.data[2].tags[0]}} | Deeply nested array access |
| {{process.output.results}} | Entire array or object (passed as-is) |
If a path does not exist at runtime, the expression resolves to undefined and the resulting string contains the literal text undefined.
Common Patterns
Pass webhook data to the next step
{
"ref": "extract",
"action": "transform",
"config": {
"template": {
"name": "{{trigger.output.body.name}}",
"email": "{{trigger.output.body.email}}"
}
}
}Chain API responses
// Step 1: Fetch user data
{
"ref": "get_user",
"action": "http",
"config": { "url": "https://api.example.com/users/123", "method": "GET" }
}
// Step 2: Use the response in another call
{
"ref": "get_orders",
"action": "http",
"config": {
"url": "https://api.example.com/orders?user={{get_user.output.body.id}}",
"method": "GET"
}
}Conditional execution with run_if
{
"ref": "premium_notify",
"action": "slack",
"config": {
"channel": "#vip",
"textTemplate": "VIP order from {{get_user.output.body.name}}"
},
"credential": "slack",
"run_if": "{{get_user.output.body.tier}} == premium"
}String interpolation in templates
{
"ref": "compose",
"action": "transform",
"config": {
"template": {
"subject": "Order #{{trigger.output.body.order_id}} confirmed",
"body": "Hi {{trigger.output.body.customer_name}},\n\nYour order of {{trigger.output.body.items_count}} items totaling ${{trigger.output.body.total}} has been confirmed.\n\nTrack at: https://example.com/track/{{trigger.output.body.tracking_id}}"
}
}
}AI step with previous output
{
"ref": "summarize",
"action": "ai",
"config": {
"prompt": "Summarize this article in 3 bullet points:\n\n{{fetch_article.output.body}}",
"responseFormat": "text"
}
}
// Access the AI output in the next step:
// {{summarize.output.text}} — the text response
// {{summarize.output.json.key}} — if responseFormat was "json"Provider.action Output
Critical: Dynamic integration steps (provider.action) and http steps wrap the API response in an envelope. You must access data through .body.
The runtime output of any provider.action or http step is always:
{
"status": 200,
"statusCode": 200,
"statusText": "OK",
"body": { ... actual API response ... },
"headers": { ... }
}This means you must use .body. to reach the API data:
| Correct | Incorrect |
|---|---|
| {{fetch_news.output.body.articles}} | {{fetch_news.output.articles}} |
| {{call_api.output.body.data[0].id}} | {{call_api.output.data[0].id}} |
| {{call_api.output.status}} | (status is at the top level, not inside body) |
This applies to all provider.action steps (e.g. newsapi.get_top_headlines, pinterest.create_pin) and all http steps. Built-in actions like telegram, slack, code, and transform have their own output shapes and do not use the envelope.
Expressions in Code Steps
Inside code steps, you access data differently. Instead of double-brace templates, you work with native JavaScript objects:
| Variable | Description |
|---|---|
| steps | JavaScript object with all previous step outputs keyed by ref. E.g. steps.fetch_data.body.items |
| trigger | JavaScript object with trigger output. E.g. trigger.body.user_id (webhook) |
| input | The step's own resolved config object |
{
"ref": "process",
"action": "code",
"config": {
"expression": "const items = steps.fetch_data.body.results; const userId = trigger.body.user_id; return { count: items.length, userId, timestamp: new Date().toISOString() };"
}
}Unlike {{expression}} templates, the steps and trigger variables in code steps are real JavaScript objects — you can iterate, destructure, and call methods on them directly.