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.
Recipe Format
The agent-friendly JSON workflow definition. Recipes compile to the internal IR for execution.
What is a Recipe?
A Recipe is a JSON object that describes a complete workflow. It is the format that agents (and humans) use to define what S6S should execute. Recipes are designed to be simple to produce — no UUIDs, no canvas positions, no internal plumbing.
You deploy a Recipe with POST /api/v1/workflows/deploy. S6S validates it, compiles it to the internal representation (IR), checks credentials, saves a new version, and (for some trigger types) starts a run. The compilation pipeline:
Recipe (agent-friendly JSON)
→ compileRecipe() → IR Workflow (typed, UUID-based, positioned)
→ compile() → CompiledPlan (topological order, parallel groups, retry policies)The same endpoint also accepts a raw IR workflow (a body with nodes and edges) or an n8n workflow export (nodes and connections). A body that is none of these gets 400.
If the Recipe is invalid, the deploy returns 422 with every problem found:
{
"error": "Recipe validation failed",
"details": {
"ok": false,
"format": "recipe",
"errors": [
{
"code": "STEP_NOT_IN_FLOW",
"message": "Step 'notify' is defined but not included in the flow array",
"path": ["steps"],
"suggestion": "Add 'notify' to the flow array or remove it from steps"
}
]
}
}When the only problems are refs in flow or on_error that are a close misspelling of a real step ref, S6S fixes them and lists the fixes under corrections in the deploy response. Non-blocking hints come back as validation_warnings.
Top-level Fields
A Recipe accepts only these fields. Any other key is rejected.
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Workflow name, up to 256 characters. |
| trigger | object | Yes | How the workflow starts: manual, schedule, webhook or telegram. See Trigger Types. |
| steps | array | Yes | At least one step definition. |
| flow | array | Yes | Execution order by step ref. Strings run in sequence; nested arrays (two or more refs) run in parallel. |
| on_error | object | No | Error policy per step, keyed by step ref: { "<ref>": { "action": "stop" | "continue" | "retry" } }. |
| description | string | No | Free text, up to 1024 characters. |
| version | integer | No | Recipe version number, 1 or higher (default 1). The stored workflow version is assigned by S6S on each deploy. |
| metadata | object | No | Only { "created_by": string, "tags": string[] }. |
{
"name": "Hello World",
"trigger": { "type": "manual" },
"steps": [
{
"ref": "greet",
"action": "transform",
"config": { "template": { "message": "Hello from S6S!" } }
}
],
"flow": ["greet"]
}Deploy request options
These fields are not part of the Recipe. You can send them in the same deploy request body; S6S removes them before compiling.
| Field | Type | Description |
|---|---|---|
| slug | string | Workflow identifier. Defaults to the name in lowercase, with every run of other characters replaced by a hyphen. Slugs are unique across the install; deploying again with a slug you own adds a new version of that workflow. |
| autoRun | boolean | Default true. Manual-triggered workflows start one run right after deploy. Webhook- and telegram-triggered workflows wait for their first request or message, and start one on deploy only when you send true explicitly. Schedule-triggered workflows never start a run on deploy. No run starts while credentials are missing. |
| callback_url | string | An http(s) URL. When the run started by this deploy finishes, S6S POSTs { run_id, status, timestamp } to it. Later runs of the workflow do not call it. |
| waitForCompletion | boolean | If true and a run started, the response waits up to 30 seconds and adds run_result: { status, output, durationMs, error? }. output holds step outputs keyed by node ID. After 30 seconds, status is "timeout". |
The raw IR format has two workflow-level fields a Recipe cannot set: inputSchema (JSON Schema that webhook input must match, see Triggers) and settings.
Trigger Types
The trigger field determines how the workflow starts. Its keys sit directly on the object (there is no nested config). Four types are supported; the Triggers page covers each one in depth.
Manual
Runs once right after deploy (unless autoRun is false), and whenever you start it from the app or with POST /api/v1/workflows/{id}/run.
"trigger": { "type": "manual" }Schedule
Runs on a five-field cron expression (cron, required) in an IANA timezone (default UTC). S6S checks schedules once a minute. Deploying does not start a run.
"trigger": {
"type": "schedule",
"cron": "0 */4 * * *",
"timezone": "America/New_York"
}Schedule triggers provide {{trigger.output.fired_at}} and {{trigger.output.scheduledAt}}, the same ISO timestamp, set when the scheduled run starts.
Webhook
Runs when an HTTP request reaches /api/webhooks{path}. path is required; method is GET or POST (default POST). The deploy response returns the invoke URL and, on the first deploy, the webhook secret that callers must send.
"trigger": {
"type": "webhook",
"path": "/new-lead",
"method": "POST"
}Webhook trigger outputs are available via expressions:
- {{trigger.output.body}} — request body (parsed for JSON requests)
- {{trigger.output.headers}} — request headers
- {{trigger.output.query}} — query string parameters
- {{trigger.output.method}} — HTTP method
- {{trigger.output.path}} — the trigger path
- {{trigger.output.receivedAt}} — ISO time the request arrived
Telegram
Runs when a Telegram chat linked to your S6S account sends a message to the S6S Telegram bot. An optional credential label can be set on the trigger.
"trigger": { "type": "telegram" }Steps
Each step defines a single action in the workflow. Steps are referenced by their ref in the flow array, in on_error, and in expressions. A step accepts only these fields.
| Field | Type | Required | Description |
|---|---|---|---|
| ref | string | Yes | Unique within the recipe. Lowercase letters, digits and underscores, starting with a letter; up to 64 characters. |
| action | string | Yes | A built-in action (see below) or provider.action for dynamic integrations. |
| config | object | Yes | Action-specific settings. String values support double-brace expressions. The accepted keys per action are listed on Node Types; unknown keys fail the deploy. |
| credential | string | No | Which connected account to use: "default" (any connected account for the action's provider), an account label, or a credential ID. The provider comes from the action; for http steps the value also names the provider. Actions that need a credential use "default" when you leave it out. |
| run_if | string | No | Expression evaluated at runtime. If falsy, the step is skipped (not failed). See Conditional Execution. |
| retry | object | No | Retry policy for this step. See Error Handling. |
| description | string | No | Display name for the step in the app. Defaults to the action and ref. |
{
"ref": "notify_slack",
"action": "slack",
"config": {
"channel": "#sales",
"textTemplate": "New lead: {{trigger.output.body.name}}"
},
"credential": "default",
"run_if": "{{trigger.output.body.score}} >= 50",
"retry": { "max_attempts": 3, "base_delay_ms": 2000 }
}Actions
The action field on each step determines what it does. S6S supports 28 built-in actions and dynamic integrations.
Built-in Actions
The Credential column names the provider of the connected account the step needs. http takes an optional credential; ai and rag use the LLM configured for the install.
| Action | Node type | Credential | Description |
|---|---|---|---|
http | action.http | No | HTTP request to a public URL. GET, POST, PUT, PATCH or DELETE (default POST). A non-2xx response fails the step. |
email | action.email | smtp | Send a plain-text email via SMTP, Resend, SendGrid or Postmark. |
slack | action.slack | slack | Post a message to a Slack channel. |
transform | action.transform | No | Build an object from a template. Expressions in the template are resolved. |
database | action.database | database | Run a SQL query against a PostgreSQL database. |
code | action.code | No | Run JavaScript in a sandbox with access to steps, trigger and input. |
filter | action.filter | No | Filter, map, find or reduce an array with a JavaScript expression. |
rss | action.rss | No | Fetch and parse an RSS or Atom feed. |
twitter | action.twitter | Post a tweet or search tweets. | |
google_sheets | action.google_sheets | Append rows, read rows, or update cells in a Google Sheet. | |
google_drive | action.google_drive | List, upload or download files in Google Drive. | |
gmail | action.gmail | Search, read or send Gmail messages. | |
discord | action.discord | discord | Send a message to a Discord channel. |
telegram | action.telegram | telegram | Send a message, photo or document, forward a message, or read bot updates. |
webhook_response | action.webhook_response | No | Record a status code, headers and body as step output. It does not change the reply sent to the webhook caller. |
execute_workflow | action.execute_workflow | No | Start another workflow by slug, and optionally wait for its result. |
ai | action.ai | No | Call the LLM configured for the install. Supports JSON output, tools, agent mode, memory, skills, workflow tools and images. |
stop_and_error | action.stop_and_error | No | Fail the step, and the run, with a custom message. |
imap | action.imap | imap | Fetch, search or read email over IMAP. |
youtube | action.youtube | Upload, list, get or update YouTube videos. | |
buffer | action.buffer | buffer | Create, update, delete or publish posts through Buffer; list channels, queued and sent posts. |
mcp_tool | action.mcp_tool | No | Call one tool on a remote MCP server. |
rag | action.rag | No | Knowledge-base search. No vector store is connected in this release, so the step fails with RAG_NO_VECTOR_STORE. |
wait | logic.wait | No | Pause for a number of seconds. |
condition | logic.condition | No | Compare a value and run one of two branches. |
switch | logic.switch | No | Match a value against cases and run the matching branch. |
loop | logic.loop | No | Resolve an expression to an array, capped at maxIterations. It does not repeat later steps per item. |
merge | logic.merge | No | Collect the outputs of the steps that lead into it into one object keyed by step ref. |
Sub-action shorthand
For gmail, google_sheets, google_drive, telegram, twitter, discord, imap, youtube and buffer you can write the sub-action into the action name. "action": "gmail.search_messages" compiles as "action": "gmail" with config.action set to search_messages. A config.action you set yourself wins.
Dynamic Integrations
For a platform not covered by built-in actions, use the provider.action syntax (lowercase, one dot). S6S compiles it to an HTTP request from a learned description of that API and injects the credential.
| Example | Description |
|---|---|
| notion.create_page | Create a page in Notion |
| pinterest.create_pin | Create a pin on Pinterest |
| newsapi.get_top_headlines | Fetch top headlines from NewsAPI |
| stripe.create_charge | Create a charge via Stripe API |
S6S first looks the integration up in its store of learned integrations. When the learning module is installed and an LLM is configured, an unknown provider is learned at deploy time and cached for later workflows. If an integration can't be resolved, the step can't run: depending on its config, the deploy either returns an UNRESOLVED_INTEGRATION warning or fails. The step's output has the same shape as an http step. Prefer provider.action over raw http for any API that requires authentication.
Flow Declaration
The flow array controls execution order. It contains step refs as strings, with nested arrays for parallel execution.
Sequential
Steps listed as strings execute one after another. Here fetch_data runs first, then process, then notify:
"flow": ["fetch_data", "process", "notify"]
Parallel
Steps inside a nested array (two or more refs) run concurrently. The next item waits for all of them. Here post_slack, post_discord and send_email run in parallel after prepare, and summarize runs when all three are done:
"flow": ["prepare", ["post_slack", "post_discord", "send_email"], "summarize"]
A step can only reference outputs of steps from earlier flow items. Steps in the same parallel group can't reference each other; the deploy rejects that with EXPRESSION_REF_NOT_UPSTREAM.
Mixed
Combine sequential and parallel items freely. Here step_a and step_b run in sequence, step_c and step_d in parallel, and step_e after both:
"flow": ["step_a", "step_b", ["step_c", "step_d"], "step_e"]
Condition branching
A nested array whose first ref is a "action": "condition" step is a branch: [condition, true_branch, false_branch]. The false branch is optional. Here check is evaluated after prep; yes_path runs if it is true, no_path if it is false, and finalize runs after whichever branch ran:
"flow": ["prep", ["check", "yes_path", "no_path"], "finalize"]
For routing on more than two values, use a switch step.
Important: Every step in steps must appear in the flow array, and every ref in flow must be defined in steps. Otherwise the deploy is rejected with 422 (STEP_NOT_IN_FLOW or UNKNOWN_STEP_REF).
Error Handling
The top-level on_error field sets a policy per step, keyed by step ref. Steps themselves have no on_error field; a step-level retry is covered below.
| Action | Behavior |
|---|---|
| stop | Default. A failed step fails the run. |
| continue | The failed step is recorded as handled (its output carries errorRouted: true and actualError) and the run moves on to the next flow item. If that item is a parallel group, only its first step runs. On the last flow item it has no effect. |
| retry | Retries the step with the "retry" object's policy: the same fields and defaults as a step-level retry (below). Without a "retry" object it does nothing. It replaces any step-level retry. |
"on_error": {
"flaky_api": {
"action": "retry",
"retry": { "max_attempts": 3 }
},
"optional_notify": { "action": "continue" },
"critical_step": { "action": "stop" }
}Step-level retry
Set retry on a step for full control:
| Field | Default | Description |
|---|---|---|
| max_attempts | 3 | Total attempts, 1–10. |
| base_delay_ms | 1000 | First delay, 100–60000 ms. |
| max_delay_ms | 30000 | Upper bound for the delay, 1000–300000 ms. |
| backoff | exponential | How the delay grows: exponential doubles it on each attempt, linear adds base_delay_ms each time, fixed keeps it at base_delay_ms. |
Each delay is a random value up to the computed delay. Only temporary errors are retried: HTTP 5xx and 429 responses, HTTP timeouts and request failures, step timeouts, AI timeouts, and sub-workflow timeouts. Other errors (for example HTTP 4xx, expression errors or missing credentials) fail at once.
Conditional Execution
Steps can include an optional run_if field. S6S resolves its expressions just before the step and evaluates the resulting string. If it is falsy, the step is skipped, not failed, and the run can still succeed.
Supported comparisons: >, <, >=, <=, ==, !=, ===, !==. When both sides are numbers they are compared as numbers; otherwise as strings. Without a comparison, the value is falsy if it is empty, false, 0, null or undefined. Keep the operator outside the braces: "{{amount.output.total}} > 100".
{
"name": "High Value Alert",
"trigger": { "type": "manual" },
"steps": [
{
"ref": "get_total",
"action": "transform",
"config": {
"template": { "amount": "{{trigger.output.payload.amount}}" }
}
},
{
"ref": "notify",
"action": "telegram",
"config": {
"action": "send_message",
"text": "High value alert: {{get_total.output.amount}}"
},
"credential": "default",
"run_if": "{{get_total.output.amount}} > 10000"
}
],
"flow": ["get_total", "notify"]
}Run it with input { "amount": 5000 } and the notify step is skipped; the run finishes as succeeded.
Full Example
A multi-step workflow with a webhook trigger, parallel steps, error handling, and credentials:
{
"name": "New Lead Notification Pipeline",
"trigger": {
"type": "webhook",
"path": "/new-lead",
"method": "POST"
},
"steps": [
{
"ref": "format_message",
"action": "transform",
"config": {
"template": {
"text": "New lead: {{trigger.output.body.name}} ({{trigger.output.body.email}})",
"name": "{{trigger.output.body.name}}",
"email": "{{trigger.output.body.email}}"
}
}
},
{
"ref": "notify_slack",
"action": "slack",
"config": {
"channel": "#sales",
"textTemplate": "{{format_message.output.text}}"
},
"credential": "default"
},
{
"ref": "log_to_sheet",
"action": "google_sheets",
"config": {
"action": "append_row",
"spreadsheetId": "your-spreadsheet-id",
"range": "Leads!A:B",
"values": [["{{format_message.output.name}}", "{{format_message.output.email}}"]]
},
"credential": "default"
},
{
"ref": "record",
"action": "transform",
"config": {
"template": {
"lead": "{{format_message.output.email}}",
"sheet_range": "{{log_to_sheet.output.updatedRange}}",
"run_id": "{{run.id}}"
}
}
}
],
"flow": [
"format_message",
["notify_slack", "log_to_sheet"],
"record"
],
"on_error": {
"notify_slack": { "action": "continue" },
"log_to_sheet": { "action": "retry", "retry": { "max_attempts": 3 } }
}
}This workflow receives a lead via webhook and formats it. It then posts to Slack and appends a row to a Google Sheet in parallel. If Slack fails, the run continues to record. If the Sheets call fails with a temporary error, it is tried up to 3 times in total. The record step runs last and keeps a summary in the run. The webhook caller gets an immediate reply with the run ID; read the results with GET /api/v1/runs/{runId}.
Next: Expressions
Learn how to reference data between steps with template expressions.