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:
| Job | Types |
|---|---|
| Think | agent, ai |
| Compute | code |
| Reach out | http, integration, email, scrape, search, parallel, bucket |
| Branch and repeat | switch, map, fanout, while, wait |
| Remember | stream, disk, database, postgres |
| Compose | workflow |
| Ask a person | review (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:
| Field | Description |
|---|---|
label | Display name. Defaults to the node name. |
description | Longer description. |
secrets | Object literal mapping secret block names to var arrays: { block: [VAR1] }. |
review | Human-in-the-loop review config. See Reviews. |
failurePolicy | Retry, skip, or fallback on failure. See Failure policies. |
format | Output format hint for the Portal: markdown, html, text, image, video, audio, mixed, or json. |
code
Run TypeScript in an isolated sandbox.
| Field | Type | Required | Description |
|---|---|---|---|
| code | @ts { } block | Yes | TypeScript to execute. Access context.nodes for upstream data. Return value becomes output. |
| schema | @json { } block | No | Output 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.
| Field | Type | Required | Description |
|---|---|---|---|
| kind | string | Yes | One of: text, object, image, video, embed. |
| provider | string | No | Which API to call: openrouter (default), anthropic, openai, or google. |
| model | string | Yes (at runtime) | Model identifier (e.g. "google/gemini-2.5-flash", "openai/dall-e-3"). |
| prompt | @ts { } block | Yes (at runtime) | TypeScript expression returning the prompt string. |
| temperature | number | No | Sampling temperature. |
| maxTokens | number | No | Max output tokens. |
| options | object | No | Kind-specific options (e.g. { n: 1, size: "1024x1024" } for image). |
| schema | @json { } block | No | Output 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.
| Field | Type | Required | Description |
|---|---|---|---|
| cases | string array | Yes | List of case names. |
| router | @ts { } block | Yes | TypeScript 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).
| Field | Type | Required | Description |
|---|---|---|---|
| url | @ts { } block or string | Yes | Request URL. |
| method | string | No | HTTP method. Defaults to GET. |
| auth | identifier | No | Name of a top-level auth credential profile. Mutually exclusive with connection:. |
| connection | identifier | No | Name of a top-level connection block for brokered OAuth. Mutually exclusive with auth:. |
| headers | object | No | Request headers. Avoid keys with hyphens. |
| body | @ts { } block | No | Request body. |
| schema | @json { } block | No | Output 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.
| Field | Type | Required | Description |
|---|---|---|---|
| connection | identifier | Yes | Name of a top-level connection block. |
| action | identifier | Preferred | Name of a top-level action block declaring the provider operation. Do not set method or path on the node when using action. |
| path | string | Legacy | Provider API path when action: is omitted (untyped params and output). |
| method | string | No | GET (default), POST, PUT, DELETE, or PATCH. Raw path: only. |
| params | @ts { } block | No | Returns the request body (POST/PUT/PATCH) or query params (GET). |
| schema | @json { } block | No | Types 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.
Send email via Resend with dynamic content.
| Field | Type | Required | Description |
|---|---|---|---|
| from | @ts { } block or string | Yes | Sender address. |
| to | @ts { } block or string | Yes | Recipient address. |
| subject | @ts { } block or string | Yes | Subject line. |
| text | @ts { } block or string | No | Plain text body. |
| html | @ts { } block or string | No | HTML body. |
| replyTo | @ts { } block or string | No | Reply-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.
| Field | Type | Required | Description |
|---|---|---|---|
| stream | identifier | Yes | Name of a top-level stream block. |
| version | v1, v2, … | Yes | Which versions: entry to read from that stream. |
| filter | @ts { } block | Yes* | Filter object for rows. Operators: eq, ne, gt, gte, lt, lte, like, in. Return {} for no filter. |
| schema | @json { } block | Recommended | Typically 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.
| Field | Type | Required | Description |
|---|---|---|---|
| postgres | string | Yes | Name of a top-level postgres block. |
| select | @sql { } block | Yes* | Read-only SELECT (or WITH CTE) query. |
| insert | @sql { } block | Yes* | INSERT statement. |
| update | @sql { } block | Yes* | UPDATE statement. The validator warns when it has no WHERE clause. |
| delete | @sql { } block | Yes* | DELETE statement. The validator warns when it has no WHERE clause. |
| params | @ts { } block | Yes for insert; for the others, required when the SQL has {{key}} placeholders | Returns an object whose keys match {{key}} tokens in the SQL. |
| condition | @ts { } block | No | Write nodes only (insert, update, delete): if present, the write runs only when this returns true. |
| schema | @json { } block | Recommended for select | JSON 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.
| Field | Type | Required | Description |
|---|---|---|---|
| database | string | Yes | Name of a top-level database block. |
| operation | query | insert | update | delete | transaction | Yes | Narrows which client methods run can call. |
| condition | @ts { } block | No | If present and returns false, the node is skipped (output { skipped: true }). |
| run | @ts { } block | Yes | The typed Prisma body, executed against the operation-narrowed client. |
Operation → client capability:
| operation | Client exposes |
|---|---|
query | findMany, findFirst, findUnique, count, aggregate, groupBy |
insert | create, createMany |
update | update, updateMany, upsert |
delete | delete, deleteMany |
transaction | The 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.
| Field | Type | Required | Description |
|---|---|---|---|
| workflow | identifier | Yes | Name of the workflow to execute (bare identifier, not a quoted string). |
| input | @ts { } block | Yes | TypeScript 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.
| Field | Type | Required | Description |
|---|---|---|---|
| items | @ts { } block | Yes | Returns an array to iterate. |
| maxItems | number | Yes | Maximum items to process. |
| subgraph | block | One of subgraph or workflow | Inline child workflow with root, optional nodes, and flow. |
| workflow | identifier | One of subgraph or workflow | Reference 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.
| Field | Type | Required | Description |
|---|---|---|---|
| items | @ts { } block | Yes | Returns an array to iterate. |
| maxItems | number | Yes | Maximum items to process. |
| concurrency | number | Yes | Maximum child workflows running at once; integer from 1 through 64. |
| subgraph | block | One of subgraph or workflow | Inline child workflow with a typed root. |
| workflow | identifier | One of subgraph or workflow | Reference 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: ... }.
| Field | Type | Required | Description |
|---|---|---|---|
| input | @ts { } block | Yes | Initial state for the loop. |
| condition | @ts { } block | Yes | If true, another iteration runs (after update). |
| update | @ts { } block | Yes | Produces the next input for the child workflow. |
| maxIterations | number | Yes | Hard cap on iterations. |
| subgraph | block | One of subgraph or workflow | Inline child workflow. |
| workflow | identifier | One of subgraph or workflow | Named 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.
| Field | Type | Required | Description |
|---|---|---|---|
| url | @ts { } block | Yes | URL to scrape. |
| onlyMainContent | boolean | No | Strip navigation and boilerplate when true. |
| formats | array of strings | No | Response formats (e.g. markdown). |
| maxAge | number | No | Cache max age hint. |
| parsers | array of strings | No | Parser hints for the API. |
The output shape is vendor-managed. Setting schema: on a scrape node will be rejected by the validator.
search
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.
| Field | Type | Required | Description |
|---|---|---|---|
| query | @ts { } block or string | Yes | Search query. |
| limit | number | No | Max results (default 5, max 20). |
| sources | array | No | web, news, and/or images. |
| categories | array | No | github, research, and/or pdf. |
| location | string | No | Location bias. |
| country | string | No | Country code (e.g. US). |
| tbs | string | No | Time-based filter (e.g. qdr:w); web only. |
| includeDomains | array of strings | No | Only include these domains. |
| excludeDomains | array of strings | No | Exclude these domains. |
| scrapeFormats | array of strings | No | When 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.
| Field | Type | Required | Description |
|---|---|---|---|
| operation | search | extract | findall | Yes | API mode. |
| objective | @ts { } block | Yes | Return a string describing the goal. |
| searchQueries | @ts { } block | Search | Return string[] of keyword queries. |
| urls | @ts { } block | Extract | Return string[] of URLs. |
| mode | string | No | Search: one-shot, agentic, or fast (default fast). |
| excerptsMaxCharsPerResult | number | No | Search: excerpt size hint per result. |
| excerptsMaxCharsTotal | number | No | Search: total excerpt budget. |
| excerpts | boolean | No | Extract: include excerpts. |
| fullContent | boolean | No | Extract: include full content. |
| entityType | @ts { } block | FindAll | Entity type label. |
| matchConditions | @ts { } block | FindAll | Match rules array. |
| matchLimit | number | No | FindAll: max matches. |
| pollInterval | number | No | FindAll: polling interval. |
| pollTimeout | number | No | FindAll: max wait before timeout. |
| pollIntervalUnit | string | No | FindAll: seconds or minutes. |
| pollTimeoutUnit | string | No | FindAll: seconds or minutes. |
| generator | string | No | FindAll: base, core, pro, or preview. |
| excludeList | @ts { } block | No | FindAll: URLs or entities to skip. |
wait
Pause execution for a duration.
| Field | Type | Required | Description |
|---|---|---|---|
| amount | number | No | Numeric delay amount. |
| unit | string | No | One of: seconds, minutes, hours, days. Use with amount. |
| secondsFromConfig | @ts { } block | No | Returns the wait duration in seconds. Dynamic alternative to amount/unit. |
bucket
Upload or download files from project storage.
| Field | Type | Required | Description |
|---|---|---|---|
| operation | string | Yes | One of: upload, download. |
| path | @ts { } block or string | No | Object path within the bucket. |
| schema | @json { } block | No | Output schema. |
disk
Execute a shell command on a platform-provisioned shared disk. Declare the disk resource in a top-level disk block first.
| Field | Type | Required | Description |
|---|---|---|---|
| disk | identifier | Yes | Name of a top-level disk block. |
| command | @ts { } block | Yes | Returns 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.
| Field | Type | Required | Description |
|---|---|---|---|
agent | identifier | Yes | Name of a top-level agent block. |
prompt | @ts { } block | Yes | Returns the user message string. |
profile | string | No | Name of a profile declared in the agent block. |
system | @ts { } block | No | Overrides the system prompt for this invocation. |
tools | identifier array | No | Narrows the effective tool set. Must be a subset of the profile's or agent's tools. |
schema | @json { } block | No | Structured output schema. Use schema, not outputSchema. |