Developer section: these pages cover the S6S workflow engine for self-hosted installs and developers. On s6s.ai, API keys and MCP access are issued by S6S on request (contact us); on a self-hosted install, an admin creates them on the API Keys page. For the AI Visibility product, start at the AI Visibility docs.

Expressions

Reference data between workflow steps using double-brace template expressions.

Syntax

Use double curly braces {{expression}} inside any config string to reference data from the trigger, earlier steps, the current run, or workflow memory.

{
  "ref": "format",
  "action": "transform",
  "config": {
    "template": {
      "greeting": "Hello, {{trigger.output.body.name}}!",
      "first_item": "{{trigger.output.body.items.0.title}}",
      "run": "{{run.id}}"
    }
  }
}

Expressions are resolved at runtime just before the step executes. Every string in the config — including strings nested in objects and arrays, up to 20 levels deep — is scanned and resolved. A step's run_if is resolved the same way.

The result is always text: strings are inserted as they are, numbers and booleans as their text form, and objects and arrays as JSON text. A path that doesn't exist resolves to an empty string.

Available Contexts

ExpressionDescription
{{trigger.output.body}}Webhook request body (parsed for JSON requests, otherwise text)
{{trigger.output.headers}}Webhook request headers (Cookie, Authorization, Proxy-Authorization, X-Webhook-Token, X-Webhook-Signature and X-Telegram-Bot-Api-Secret-Token removed)
{{trigger.output.query}}Webhook query string parameters (token removed)
{{trigger.output.method}}Webhook HTTP method; also path and receivedAt
{{trigger.output.fired_at}}Schedule trigger timestamp (also scheduledAt)
{{trigger.output.payload}}Manual trigger input; also triggeredAt and triggeredBy (always "api")
{{trigger.output.text}}Telegram trigger message text; also chatId, from, chat, messageId, date, receivedAt
{{<ref>.output.*}}Output of an earlier step, by its ref
{{steps.<ref>.output.*}}The same, in long form
{{run.id}}Unique ID of the current run
{{run.timestamp}}ISO timestamp taken when the current step's expressions are resolved. {{run.startedAt}} is an alias; neither is the run's start time.
{{memory.<key>}}A value stored in this workflow's memory (see below)

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 is more common in recipes, and only it is checked at deploy time: a reference to an unknown step, or to a step that doesn't run earlier in the flow, is rejected with EXPRESSION_REF_UNKNOWN or EXPRESSION_REF_NOT_UPSTREAM.

Any other root (anything but trigger, steps, a step ref, run and memory), an unknown run field, or a step that has no output yet fails the step at runtime. Action steps report this as EXPRESSION_RESOLUTION_FAILED.

Workflow memory

AI steps with memoryEnabled can save values with the memory_write tool. Memory belongs to the workflow and persists across runs. Any step can read it back with {{memory.<key>}}; a further path such as {{memory.customer.plan}} reads a field of a stored object. When the key (or the workflow's memory) doesn't exist, the expression resolves to an empty string. The 200 most recently updated keys are available. See action.ai.

Output Shapes by Action Type

Each action type produces a different output shape. Knowing the shape tells you which fields to reference in expressions. Per-field details are on Node Types.

ActionOutput Shape
transformThe evaluated template object. Whatever keys you define in template become output fields.
http{ status, statusCode, statusText, body } — HTTP response envelope. API data is inside .body. Binary responses return { status, statusCode, statusText, fileUrl, fileName, mimeType, sizeBytes, expiresAt }.
provider.actionSame as http — compiled to HTTP at deploy time. Always access data via .body.
codeWhatever the JavaScript returns. Objects and arrays are returned as-is; other values are wrapped in { value }.
filter{ result, count } for an array result; an object result as-is; otherwise { result }
ai{ text, model, promptTokens, completionTokens }, plus json when responseFormat is "json" or an outputSchema is set, schemaValid with an outputSchema, and toolCalls, toolCall or totalIterations when tools ran.
slack{ channel, ts }
telegram{ messageId, chatId, action, chunks } (send_message), { messageId, chatId, action } (send_photo, send_document, forward_message), { updates, updateCount, action } (get_updates)
discord{ messageId, channelId }
email{ messageId, accepted, rejected, provider } (SMTP) or { messageId, provider, status } (Resend, SendGrid, Postmark)
gmail{ messages, resultCount, account } (search), { message } (read), { messageId, threadId } (send)
google_sheets{ rowCount, values } (read_rows), { updatedRows, updatedRange } (append_row), { updatedRange, updatedCells } (update_cell)
google_drive{ fileCount, files } (list), { fileId, name, mimeType } (upload), { contentLength, content, wasTruncated } (download), { fileUrl, fileName, mimeType, sizeBytes, expiresAt } (download_media)
imap{ messages, resultCount } (fetch_recent, search), { message } (read_message)
rss{ feedTitle, itemCount, items }
twitter{ tweetId, text } (post_tweet), { resultCount, tweets } (search)
database{ rows, rowCount, operation }
execute_workflow{ runId, status, workflowSlug, result }; with waitForCompletion: false, { triggered, runId, workflowSlug }
mcp_tool{ toolName, result, isError, serverUrl }
webhook_response{ statusCode, headers, body }
stop_and_error{ message } (the step fails)
youtube, buffer, ragDepends on the sub-action; see Node Types.
condition{ conditionResult, resolvedValue, operator, compareValue, skippedNodeIds }
switch{ resolvedValue, matchedTarget, skippedNodeIds }
loop{ items, iterations, itemVariable }
wait{ waited, seconds, actualMs }
mergeOne field per incoming step, keyed by its ref, holding that step's output
skipped by run_if{ skipped, reason, runIf, resolvedValue }
failed, on_error continue{ errorRouted, fallbackNodeId, skippedNodeIds, actualError }

Nested Access

Use dot notation for both object fields and array positions. Bracket notation such as items[0] is not supported and resolves to an empty string.

ExpressionWhat 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
{{fetch.output.body.items.length}}Number of elements in an array
{{process.output.results}}Entire array or object, inserted as JSON text

If a path does not exist at runtime, the expression resolves to an empty string.

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

The second request uses the user ID returned by the first:

{
  "name": "User Orders",
  "trigger": { "type": "manual" },
  "steps": [
    {
      "ref": "get_user",
      "action": "http",
      "config": { "url": "https://api.example.com/users/123", "method": "GET" }
    },
    {
      "ref": "get_orders",
      "action": "http",
      "config": {
        "url": "https://api.example.com/orders?user={{get_user.output.body.id}}",
        "method": "GET"
      }
    }
  ],
  "flow": ["get_user", "get_orders"]
}

Conditional execution with run_if

The comparison sits outside the braces. Unquoted text such as premium is compared as a string.

{
  "name": "VIP Order Alert",
  "trigger": { "type": "manual" },
  "steps": [
    {
      "ref": "get_user",
      "action": "http",
      "config": { "url": "https://api.example.com/users/123", "method": "GET" }
    },
    {
      "ref": "premium_notify",
      "action": "slack",
      "config": {
        "channel": "#vip",
        "textTemplate": "VIP order from {{get_user.output.body.name}}"
      },
      "credential": "default",
      "run_if": "{{get_user.output.body.tier}} == premium"
    }
  ],
  "flow": ["get_user", "premium_notify"]
}

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

{
  "name": "Summarize Article",
  "trigger": { "type": "manual" },
  "steps": [
    {
      "ref": "fetch_article",
      "action": "http",
      "config": { "url": "https://example.com/article", "method": "GET" }
    },
    {
      "ref": "summarize",
      "action": "ai",
      "config": {
        "prompt": "Summarize this article in 3 bullet points:\n\n{{fetch_article.output.body}}",
        "responseFormat": "text"
      }
    }
  ],
  "flow": ["fetch_article", "summarize"]
}

A later step reads the answer with {{summarize.output.text}}. With "responseFormat": "json", the parsed reply is at {{summarize.output.json.<key>}}. Note that a non-JSON page body is cut at 4096 characters by the http step.

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 output of a provider.action or http step that receives a JSON response looks like this:

{
  "status": 200,
  "statusCode": 200,
  "statusText": "OK",
  "body": { "articles": [{ "title": "First headline" }] }
}

Response headers are not included.

This means you must use .body. to reach the API data:

CorrectIncorrect
{{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 the JavaScript of a code step, you access data differently. Instead of double-brace templates, you work with native JavaScript objects:

VariableDescription
stepsEarlier step outputs keyed by ref, without .output. E.g. steps.fetch_data.body.items
triggerThe trigger output. E.g. trigger.body.user_id (webhook) or trigger.payload (manual)
inputThe step's own resolved config object
fetchHTTP requests to public addresses (private and internal addresses are blocked)
Code step example
{
  "name": "Count Results",
  "trigger": { "type": "webhook", "path": "/count-results", "method": "POST" },
  "steps": [
    {
      "ref": "fetch_data",
      "action": "http",
      "config": { "url": "https://api.example.com/results", "method": "GET" }
    },
    {
      "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() };"
      }
    }
  ],
  "flow": ["fetch_data", "process"]
}

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.

Learn the Recipe Format

See how expressions fit into the full workflow definition.