SWIRLS_
Workflows

Node Types

Configuration reference for all 20 node types available in .swirls files.

What it is. The steps inside a workflow: the verbs of the language. Each node has a type field that determines its behavior.

Twenty types, seven jobs:

JobTypes
Thinkagent, ai
Computecode
Reach outhttp, integration, email, scrape, search, parallel, bucket
Branch and repeatswitch, map, fanout, while, wait
Rememberstream, disk, database, postgres
Composeworkflow
Ask a personreview (a config on any node, see Reviews)

This page documents all 20 node types, their required and optional fields, and examples. The complete list of valid type values: agent, ai, bucket, code, database, disk, email, fanout, http, integration, map, parallel, postgres, scrape, search, stream, switch, wait, while, workflow.

parallel is not workflow concurrency. Despite the name, type: parallel calls Parallel.ai for web research (search, extract, findall). For query-centric web discovery via Firecrawl, use type: search. Use map for sequential per-item work or fanout for independent concurrent per-item work. Independent DAG branches need no special node type; ready branches run concurrently up to the workflow's concurrency: cap.

For how nodes connect to each other, see Workflows. For type-safe access to node outputs, see Context. For per-node credit costs on Swirls Cloud, see Credits.

Shared optional fields

Every node accepts these fields in addition to its type-specific fields:

FieldDescription
labelDisplay name. Defaults to the node name.
descriptionLonger description.
secretsObject literal mapping secret block names to var arrays: { block: [VAR1] }.
reviewHuman-in-the-loop review config. See Reviews.
failurePolicyRetry, skip, or fallback on failure. See Failure policies.
formatOutput format hint for the Portal: markdown, html, text, image, video, audio, mixed, or json.

code

Run TypeScript in an isolated sandbox.

FieldTypeRequiredDescription
code@ts { } blockYesTypeScript to execute. Access context.nodes for upstream data. Return value becomes output.
schema@json { } blockNoOutput schema (JSON Schema). Use outputSchema on the root node only.
node normalize {
  type: code
  label: "Normalize email"
  schema: @json {
    { "type": "object", "required": ["email"], "properties": { "email": { "type": "string" } }, "additionalProperties": false }
  }
  code: @ts {
    const email = context.nodes.root.input.email ?? ""
    return { email: email.toLowerCase().trim() }
  }
}

A code node has no database access: context.db is not in scope here. Query or mutate a managed database from a database node and read its output downstream.

Budget: a code node's code: body has 30 seconds of wall-clock time to complete. Time spent awaiting I/O (an HTTP call, a downstream request) counts against it. Exceeding it fails the node. Split long-running work across multiple nodes, since each node starts its own fresh budget.

ai

Invoke an AI model. Supports multiple kinds: text, object, image, video, and embed.

FieldTypeRequiredDescription
kindstringYesOne of: text, object, image, video, embed.
providerstringNoWhich API to call: openrouter (default), anthropic, openai, or google.
modelstringYes (at runtime)Model identifier (e.g. "google/gemini-2.5-flash", "openai/dall-e-3").
prompt@ts { } blockYes (at runtime)TypeScript expression returning the prompt string.
temperaturenumberNoSampling temperature.
maxTokensnumberNoMax output tokens.
optionsobjectNoKind-specific options (e.g. { n: 1, size: "1024x1024" } for image).
schema@json { } blockNoOutput schema. Required for the object kind.

Example (object kind):

node classify {
  type: ai
  label: "Classify intent"
  kind: object
  model: "anthropic/claude-3.5-sonnet"
  prompt: @ts {
    return `Classify this message: ${context.nodes.root.input.message}`
  }
  schema: @json {
    {
      "type": "object",
      "required": ["intent", "confidence"],
      "properties": {
        "intent": { "type": "string" },
        "confidence": { "type": "number" }
      }
    }
  }
}

Example (image kind):

node generate_image {
  type: ai
  label: "Generate image"
  kind: image
  model: "openai/dall-e-3"
  prompt: @ts {
    return `A professional illustration of: ${context.nodes.root.input.topic}`
  }
  options: {
    n: 1
    size: "1024x1024"
  }
}

switch

Conditional routing. Returns a case name to determine which branch executes.

FieldTypeRequiredDescription
casesstring arrayYesList of case names.
router@ts { } blockYesTypeScript expression returning one of the case names.

Use labeled edges in the flow block: route -["case_name"]-> target.

node route {
  type: switch
  label: "Route by priority"
  cases: ["high", "medium", "low"]
  router: @ts {
    const score = context.nodes.root.output.score ?? 0
    if (score > 80) return "high"
    if (score > 40) return "medium"
    return "low"
  }
}

http

Make HTTP requests to external APIs. Attach a credential profile with auth: for OAuth, API key, basic, or bearer auth, or a connection with connection: for brokered OAuth. Set one, not both. Avoid building Authorization headers by hand; also avoid hyphenated header keys like Content-Type in literal headers objects (they break the parser; see Syntax).

FieldTypeRequiredDescription
url@ts { } block or stringYesRequest URL.
methodstringNoHTTP method. Defaults to GET.
authidentifierNoName of a top-level auth credential profile. Mutually exclusive with connection:.
connectionidentifierNoName of a top-level connection block for brokered OAuth. Mutually exclusive with auth:.
headersobjectNoRequest headers. Avoid keys with hyphens.
body@ts { } blockNoRequest body.
schema@json { } blockNoOutput schema.

The node's output is the parsed response body directly (JSON value or raw text); there is no { status, headers, body } envelope. Status, status text, content type, and duration are available at context.nodes.<name>.meta.

auth api_key_ex {
  type: api_key
  secrets: api_k
  key: API_KEY
  header: "X-Api-Key"
}

secret api_k {
  vars: [API_KEY]
}

node fetch_data {
  type: http
  label: "Fetch from API"
  method: "POST"
  auth: api_key_ex
  url: @ts {
    return "https://api.example.com/data/" + context.nodes.root.input.id
  }
  body: @ts {
    return JSON.stringify({ query: context.nodes.root.input.query })
  }
  schema: @json {
    { "type": "object", "properties": { "results": { "type": "array" } } }
  }
}

integration

Call a provider API through a project connection. Swirls brokers a short-lived access token for the bound account and proxies the request, so no provider credentials live in your files. Prefer a typed top-level action block, installed from the integration catalog with swirls add; a raw path: is the untyped legacy form.

FieldTypeRequiredDescription
connectionidentifierYesName of a top-level connection block.
actionidentifierPreferredName of a top-level action block declaring the provider operation. Do not set method or path on the node when using action.
pathstringLegacyProvider API path when action: is omitted (untyped params and output).
methodstringNoGET (default), POST, PUT, DELETE, or PATCH. Raw path: only.
params@ts { } blockNoReturns the request body (POST/PUT/PATCH) or query params (GET).
schema@json { } blockNoTypes the response. Raw path: only; action blocks supply the output schema.
connection team_slack {
  label: "Team Slack"
  provider: slack
}

action slack_post_message {
  provider: slack
  method: POST
  path: "/chat.postMessage"
  encoding: form
  input: @json {
    { "type": "object", "required": ["channel", "text"], "properties": { "channel": { "type": "string" }, "text": { "type": "string" } } }
  }
  output: @json {
    { "type": "object", "required": ["ok"], "properties": { "ok": { "type": "boolean" } } }
  }
}

node post_slack {
  type: integration
  label: "Post to Slack"
  connection: team_slack
  action: slack_post_message
  params: @ts {
    return {
      channel: context.nodes.root.output.channel,
      text: context.nodes.root.output.text,
    }
  }
}

Bind the connection to a real account in Cloud Connections before running the workflow. Use type: http with connection: instead when you need full URL control. See Connections.

email

Send email via Resend with dynamic content.

FieldTypeRequiredDescription
from@ts { } block or stringYesSender address.
to@ts { } block or stringYesRecipient address.
subject@ts { } block or stringYesSubject line.
text@ts { } block or stringNoPlain text body.
html@ts { } block or stringNoHTML body.
replyTo@ts { } block or stringNoReply-to address.

The output shape is vendor-managed. Setting schema: on an email node will be rejected by the validator.

node send_email {
  type: email
  label: "Send notification"
  from: @ts { return "[email protected]" }
  to: @ts { return context.nodes.root.output.email }
  subject: @ts { return "Your request has been processed" }
  text: @ts {
    return `Hello ${context.nodes.root.output.name}, your request is complete.`
  }
}

stream

Read persisted rows from a top-level stream block anywhere in the workspace, pinned to one version. Reads return all matching rows, newest first; pagination and sorting are not configurable today. See Streams for stream { workflow: , version:, versions: { vN { schema, condition?, prepare } } } and context.output in switch workflows.

FieldTypeRequiredDescription
streamidentifierYesName of a top-level stream block.
versionv1, v2, …YesWhich versions: entry to read from that stream.
filter@ts { } blockYes*Filter object for rows. Operators: eq, ne, gt, gte, lt, lte, like, in. Return {} for no filter.
schema@json { } blockRecommendedTypically type: "array" of row objects.

*Required in practice; use return {} when unconstrained.

postgres

Query or write to a user-managed PostgreSQL database declared in a top-level postgres block. Use select: for reads and insert:, update:, or delete: for writes. Each node references exactly one of the four.

FieldTypeRequiredDescription
postgresstringYesName of a top-level postgres block.
select@sql { } blockYes*Read-only SELECT (or WITH CTE) query.
insert@sql { } blockYes*INSERT statement.
update@sql { } blockYes*UPDATE statement. The validator warns when it has no WHERE clause.
delete@sql { } blockYes*DELETE statement. The validator warns when it has no WHERE clause.
params@ts { } blockYes for insert; for the others, required when the SQL has {{key}} placeholdersReturns an object whose keys match {{key}} tokens in the SQL.
condition@ts { } blockNoWrite nodes only (insert, update, delete): if present, the write runs only when this returns true.
schema@json { } blockRecommended for selectJSON Schema for result rows (typically an array of objects).

*Exactly one of select, insert, update, or delete is required.

Select example:

node load_rows {
  type: postgres
  label: "Load active rows"
  postgres: my_db
  select: @sql {
    SELECT id, name FROM items WHERE status = {{status}} LIMIT 10
  }
  params: @ts {
    return { status: context.nodes.root.input.status }
  }
  schema: @json {
    {
      "type": "array",
      "items": {
        "type": "object",
        "properties": { "id": { "type": "string" }, "name": { "type": "string" } }
      }
    }
  }
}

Insert example:

node save_row {
  type: postgres
  label: "Insert row"
  postgres: my_db
  insert: @sql {
    INSERT INTO items (name, score) VALUES ({{name}}, {{score}})
  }
  params: @ts {
    return {
      name: context.nodes.classify.output.name,
      score: context.nodes.classify.output.score
    }
  }
}

Update example:

node refresh_score {
  type: postgres
  label: "Refresh score"
  postgres: my_db
  update: @sql {
    UPDATE items SET score = {{score}} WHERE name = {{name}}
  }
  params: @ts {
    return {
      score: context.nodes.classify.output.score,
      name: context.nodes.classify.output.name
    }
  }
}

Delete example:

node prune_rows {
  type: postgres
  label: "Prune stale rows"
  postgres: my_db
  delete: @sql {
    DELETE FROM items WHERE score < {{min_score}}
  }
  params: @ts {
    return { min_score: context.nodes.root.input.threshold }
  }
}

Declare the database and tables in a top-level postgres block: see Postgres.

database

This is how a workflow reads and writes a Swirls-managed database block. operation narrows the injected client (context.db.<name>) to the declared capability, enforced at runtime, so the node is visible in flow { }, gateable with review:, and traced. operation: transaction is the one exception: it exposes the full client, inside one atomic $transaction, for a multi-step change no narrowed operation can express.

FieldTypeRequiredDescription
databasestringYesName of a top-level database block.
operationquery | insert | update | delete | transactionYesNarrows which client methods run can call.
condition@ts { } blockNoIf present and returns false, the node is skipped (output { skipped: true }).
run@ts { } blockYesThe typed Prisma body, executed against the operation-narrowed client.

Operation → client capability:

operationClient exposes
queryfindMany, findFirst, findUnique, count, aggregate, groupBy
insertcreate, createMany
updateupdate, updateMany, upsert
deletedelete, deleteMany
transactionThe full client, inside one atomic $transaction

Governed delete example:

node purge_stale {
  type: database
  label: "Purge stale users"
  database: my_db
  operation: delete
  review: { enabled: true }
  run: @ts {
    return context.db.my_db.user.deleteMany({
      where: { lastSeen: { lt: context.nodes.root.output.cutoff } },
    })
  }
}

Transaction example (the full client, atomically):

node settle_invoice {
  type: database
  label: "Settle invoice"
  database: my_db
  operation: transaction
  run: @ts {
    return context.db.my_db.$transaction(async (tx) => {
      const invoice = await tx.invoice.update({
        where: { id: context.nodes.root.output.invoiceId },
        data: { status: "PAID" },
      })
      await tx.ledgerEntry.create({
        data: { invoiceId: invoice.id, amount: invoice.total },
      })
      return invoice
    })
  }
}

Because transaction spans every operation class, it's governed at the node grain rather than per method: review: and traces treat the whole transaction as one step.

Budget: a database node's run: body has 30 seconds of wall-clock time to complete, including the query itself. Exceeding it fails the node. Batch a large scan or update over many rows with a map node instead of one long-running query.

Declare the database and its Prisma schema in a top-level database block: see Database. See Context for where context.db is in scope.

workflow

Execute another workflow as a subgraph.

FieldTypeRequiredDescription
workflowidentifierYesName of the workflow to execute (bare identifier, not a quoted string).
input@ts { } blockYesTypeScript expression mapping input for the subgraph.
node run_enrichment {
  type: workflow
  label: "Run enrichment"
  workflow: enrich_contact
  input: @ts {
    return { email: context.nodes.root.input.email, name: context.nodes.root.input.name }
  }
}

Subgraph output is available as context.nodes.<workflowNodeName>.output.<leafNodeName> where leafNodeName is a leaf node in the child workflow.

map

Run a child workflow once per element returned from items. Requires maxItems (positive cap). Use either inline subgraph { ... } (no colon after subgraph) or workflow: named_workflow: exactly one.

Inside the child workflow and on the map node’s items block, context.iteration includes index, item (typed from the subgraph root inputSchema), total, and previous. Output is an array in items order, one entry per item, each keyed by the child workflow's leaf node names: [{ <leafName>: <leafOutput> }, ...].

Iterations run one at a time, in items order; each iteration can read the previous one via context.iteration.previous. concurrency: is invalid on map; use fanout when iterations are independent.

FieldTypeRequiredDescription
items@ts { } blockYesReturns an array to iterate.
maxItemsnumberYesMaximum items to process.
subgraphblockOne of subgraph or workflowInline child workflow with root, optional nodes, and flow.
workflowidentifierOne of subgraph or workflowReference to a named workflow anywhere in the workspace.
node per_ticket {
  type: map
  label: "Process each ticket"
  items: @ts {
    return context.nodes.root.output.tickets
  }
  maxItems: 100
  subgraph {
    root {
      type: code
      label: "Normalize"
      inputSchema: @json {
        { "type": "object", "required": ["id"], "properties": { "id": { "type": "string" } }, "additionalProperties": false }
      }
      outputSchema: @json {
        { "type": "object", "required": ["id"], "properties": { "id": { "type": "string" } }, "additionalProperties": false }
      }
      code: @ts {
        const item = context.iteration.item
        return { id: item.id.trim() }
      }
    }
  }
}

fanout

Run an independent child workflow for every element with bounded concurrency. Fanout requires concurrency from 1 through 64 and preserves original item order in its output even when children finish out of order.

Inside each child, context.iteration includes index, item, and total. It does not include previous; use sequential map if one iteration depends on the last.

FieldTypeRequiredDescription
items@ts { } blockYesReturns an array to iterate.
maxItemsnumberYesMaximum items to process.
concurrencynumberYesMaximum child workflows running at once; integer from 1 through 64.
subgraphblockOne of subgraph or workflowInline child workflow with a typed root.
workflowidentifierOne of subgraph or workflowReference to a named workflow anywhere in the workspace.
node research_queries {
  type: fanout
  items: @ts { return context.nodes.root.output.queries }
  maxItems: 100
  concurrency: 8
  workflow: research_query
}

while

Run a child workflow repeatedly until condition is false or maxIterations is reached. Requires input, condition, update, and maxIterations. Same subgraph { } vs workflow: choice as map.

context.iteration includes index, input (threaded state from root inputSchema), and previous (last iteration’s leaf outputs). Output shape is { iterations: number; lastOutput: ... }.

FieldTypeRequiredDescription
input@ts { } blockYesInitial state for the loop.
condition@ts { } blockYesIf true, another iteration runs (after update).
update@ts { } blockYesProduces the next input for the child workflow.
maxIterationsnumberYesHard cap on iterations.
subgraphblockOne of subgraph or workflowInline child workflow.
workflowidentifierOne of subgraph or workflowNamed workflow anywhere in the workspace.
node refine_loop {
  type: while
  label: "Refine draft"
  input: @ts {
    return { draft: context.nodes.merge.output.text }
  }
  condition: @ts {
    return context.iteration.index < 2
  }
  update: @ts {
    return { draft: context.iteration.previous?.polish?.text ?? context.iteration.input.draft }
  }
  maxIterations: 5
  subgraph {
    root {
      type: code
      label: "Polish"
      inputSchema: @json {
        { "type": "object", "required": ["draft"], "properties": { "draft": { "type": "string" } }, "additionalProperties": false }
      }
      outputSchema: @json {
        { "type": "object", "required": ["text"], "properties": { "text": { "type": "string" } }, "additionalProperties": false }
      }
      code: @ts {
        return { text: context.iteration.input.draft + " (refined)" }
      }
    }
  }
}

Downstream code can read context.nodes.refine_loop.output.lastOutput.<leafName>.

scrape

Fetch and extract content from web pages via Firecrawl.

FieldTypeRequiredDescription
url@ts { } blockYesURL to scrape.
onlyMainContentbooleanNoStrip navigation and boilerplate when true.
formatsarray of stringsNoResponse formats (e.g. markdown).
maxAgenumberNoCache max age hint.
parsersarray of stringsNoParser hints for the API.

The output shape is vendor-managed. Setting schema: on a scrape node will be rejected by the validator.

Web discovery via Firecrawl Search. Query-centric: pass a query string (or @ts returning one) and get a normalized { query, results[] } with each result tagged source: "web" | "news" | "images". The output shape is vendor-managed; setting schema: on a search node is a validator error.

FieldTypeRequiredDescription
query@ts { } block or stringYesSearch query.
limitnumberNoMax results (default 5, max 20).
sourcesarrayNoweb, news, and/or images.
categoriesarrayNogithub, research, and/or pdf.
locationstringNoLocation bias.
countrystringNoCountry code (e.g. US).
tbsstringNoTime-based filter (e.g. qdr:w); web only.
includeDomainsarray of stringsNoOnly include these domains.
excludeDomainsarray of stringsNoExclude these domains.
scrapeFormatsarray of stringsNoWhen set, attach page content via Firecrawl scrape options.
node find {
  type: search
  query: @ts { return context.nodes.root.output.topic }
  limit: 10
  sources: ["web"]
}

Requires FIRECRAWL_API_KEY (same as scrape). For Parallel.ai’s research product (objective + searchQueries, extract, findall), use parallel instead.

parallel

Web search, URL extraction, and entity discovery via Parallel.ai. Not for running workflow steps concurrently. Use fanout for concurrent per-item iteration, map for sequential iteration, or multiple edges in flow { } for independent branches. The output shape is vendor-managed; setting schema: on a parallel node is a validator error.

FieldTypeRequiredDescription
operationsearch | extract | findallYesAPI mode.
objective@ts { } blockYesReturn a string describing the goal.
searchQueries@ts { } blockSearchReturn string[] of keyword queries.
urls@ts { } blockExtractReturn string[] of URLs.
modestringNoSearch: one-shot, agentic, or fast (default fast).
excerptsMaxCharsPerResultnumberNoSearch: excerpt size hint per result.
excerptsMaxCharsTotalnumberNoSearch: total excerpt budget.
excerptsbooleanNoExtract: include excerpts.
fullContentbooleanNoExtract: include full content.
entityType@ts { } blockFindAllEntity type label.
matchConditions@ts { } blockFindAllMatch rules array.
matchLimitnumberNoFindAll: max matches.
pollIntervalnumberNoFindAll: polling interval.
pollTimeoutnumberNoFindAll: max wait before timeout.
pollIntervalUnitstringNoFindAll: seconds or minutes.
pollTimeoutUnitstringNoFindAll: seconds or minutes.
generatorstringNoFindAll: base, core, pro, or preview.
excludeList@ts { } blockNoFindAll: URLs or entities to skip.

wait

Pause execution for a duration.

FieldTypeRequiredDescription
amountnumberNoNumeric delay amount.
unitstringNoOne of: seconds, minutes, hours, days. Use with amount.
secondsFromConfig@ts { } blockNoReturns the wait duration in seconds. Dynamic alternative to amount/unit.

bucket

Upload or download files from project storage.

FieldTypeRequiredDescription
operationstringYesOne of: upload, download.
path@ts { } block or stringNoObject path within the bucket.
schema@json { } blockNoOutput schema.

disk

Execute a shell command on a platform-provisioned shared disk. Declare the disk resource in a top-level disk block first.

FieldTypeRequiredDescription
diskidentifierYesName of a top-level disk block.
command@ts { } blockYesReturns the shell command string to run on the mounted disk.

The output envelope is fixed: stdout, stderr, exitCode, and timing. Setting schema: on a disk node is a validator error.

disk project_disk {
  label: "Project disk"
}

node list_files {
  type: disk
  label: "List files"
  disk: project_disk
  command: @ts {
    return "ls /mnt/" + context.nodes.root.input.path
  }
}

agent

Invoke a top-level agent block. The agent runs a tool-call loop: it calls tool workflows and built-in workspace tools until it produces a final answer or reaches maxSteps.

For the full treatment (the agent block, profiles, sandbox configuration, structured output, tool workflow contract, and common mistakes), see Agents.

FieldTypeRequiredDescription
agentidentifierYesName of a top-level agent block.
prompt@ts { } blockYesReturns the user message string.
profilestringNoName of a profile declared in the agent block.
system@ts { } blockNoOverrides the system prompt for this invocation.
toolsidentifier arrayNoNarrows the effective tool set. Must be a subset of the profile's or agent's tools.
schema@json { } blockNoStructured output schema. Use schema, not outputSchema.

On this page