Common mistakes
The most common errors when writing .swirls files, with corrections and the exact validator messages each one produces.
Run swirls doctor to validate your .swirls files. This page explains what to fix when it reports errors or when workflows silently disappear from the output.
Wrong node type names
There are 20 node types. Each one has an exact name. The validator rejects anything else.
The 20 valid types: agent, ai, bucket, code, database, disk, email, fanout, http, integration, map, parallel, postgres, scrape, search, stream, switch, wait, while, workflow.
type: resend should be type: email
// Incorrect
node notify {
type: resend
from: @ts { return "[email protected]" }
to: @ts { return context.nodes.root.output.email }
subject: @ts { return "Done" }
}Validator error: Invalid node type "resend". Must be one of: agent, ai, bucket, code, database, disk, email, fanout, http, integration, map, parallel, postgres, scrape, search, stream, switch, wait, while, workflow
// Correct
node notify {
type: email
label: "Send notification"
from: @ts { return "[email protected]" }
to: @ts { return context.nodes.root.output.email }
subject: @ts { return "Done" }
}type: firecrawl should be type: scrape
// Incorrect
node fetch_page {
type: firecrawl
url: @ts { return "https://example.com" }
}Validator error: Invalid node type "firecrawl". Must be one of: agent, ai, bucket, code, database, disk, email, fanout, http, integration, map, parallel, postgres, scrape, search, stream, switch, wait, while, workflow
// Correct
node fetch_page {
type: scrape
label: "Fetch page"
url: @ts { return "https://example.com" }
}Other invalid aliases
| Wrong | Correct |
|---|---|
type: api, type: request, type: fetch | type: http |
type: delay, type: sleep | type: wait |
type: llm, type: prompt, type: chat | type: ai |
type: subgraph, type: call, type: child | type: workflow |
type: db, type: sql | type: postgres (external database you operate) or type: database (Swirls-managed database block) |
type: storage, type: file, type: s3 | type: bucket |
type: loop, type: retry | type: while (for loops), failurePolicy (for retry) |
type: workers | type: fanout (independent concurrent iteration) or type: map (sequential iteration) |
type: transform, type: python, type: js | type: code |
type: google, type: bing | type: search (Firecrawl web discovery) or type: parallel (Parallel.ai research) |
parallel is not workflow concurrency
Despite the name, type: parallel calls Parallel.ai for web research: multi-query search (operation: search), URL extraction (extract), or entity discovery (findall). It does not run independent workflow branches or fan out steps.
// Incorrect — parallel is not a fan-out node
node run_all {
type: parallel
// missing operation, objective, searchQueries, etc.
}Use instead:
| You want | Primitive |
|---|---|
| Run the same child workflow sequentially for every item | map node with items: and maxItems: |
| Run independent child workflows concurrently for every item | fanout node with items:, maxItems:, and concurrency: |
| Run independent branches in one workflow | Multiple edges from the same source in flow { } (e.g. root -> enrich and root -> validate) |
| Multi-query web research | parallel node with operation: search and searchQueries: |
See Node types — parallel and From intent — research the web.
TypeScript blocks
Code written outside a @ts block
// Incorrect
node process {
type: code
label: "Process"
code: return { value: 1 }
}All executable code must be inside a @ts { } block.
// Correct
node process {
type: code
label: "Process"
code: @ts { return { value: 1 } }
}Regex literals containing quote characters
The @ts { } scanner tracks strings, template literals, and comments, but not regex literals. A quote character (", ', or a backtick) inside a regex literal is mistaken for a string boundary. The scanner desyncs and the rest of the file is silently dropped. swirls doctor reports success with fewer workflows than expected.
// Incorrect — causes silent drop
code: @ts {
return { r: s.replace(/"/g, '') }
}// Correct — build the pattern from strings instead
code: @ts {
const Q = String.fromCharCode(34)
return { r: s.split(Q).join("") }
}Regex literals without quote characters are safe: /\d+/g, /^https?:/. When a pattern needs a quote character, build it with new RegExp(...) from string parts (' is String.fromCharCode(39), " is String.fromCharCode(34)).
These patterns all parse fine and need no workarounds: nested template literals, $${amount} currency interpolation, and quote characters inside proper strings ('"', "it's"). The scanner handles strings with full escape handling. The only @ts quoting hazard is a quote character inside a regex literal.
fetch, import, and require inside @ts blocks
Code nodes run in a sandboxed environment with no network access, no file system, and no module imports. The denial is quiet: require("fs") returns a stub whose writes vanish, and network calls simply fail. Do not expect an error to point you here.
// Incorrect
node call_api {
type: code
label: "Call API"
code: @ts {
const res = await fetch("https://api.example.com/data")
return await res.json()
}
}Use dedicated node types for external calls: http for API requests, ai for LLM calls, email for email, search for web discovery, scrape for page fetch.
// Correct
node call_api {
type: http
label: "Call API"
url: @ts { return "https://api.example.com/data" }
}Schema placement
outputSchema on a non-root node
// Incorrect
node process {
type: code
label: "Process"
code: @ts { return { value: 1 } }
outputSchema: @json { { "type": "object" } }
}Validator error: Use "schema" instead of "outputSchema" in node blocks
Non-root nodes use schema, not outputSchema. Root nodes use inputSchema and outputSchema.
// Correct
node process {
type: code
label: "Process"
code: @ts { return { value: 1 } }
schema: @json { { "type": "object" } }
}inputSchema on a non-root node
// Incorrect
node enrich {
type: code
label: "Enrich"
inputSchema: @json { { "type": "object" } }
code: @ts { return {} }
}Parser error: inputSchema is only allowed in root { } blocks
The parser drops the entire node silently. Downstream edges referencing enrich will then fail with Edge references non-existent source node "enrich".
inputSchema belongs only on the root { } block. Non-root nodes receive their input from upstream node outputs via context.nodes.
Edges and routing
Chaining edges on one line
// Incorrect
flow {
root -> validate -> process -> notify
}Parser error: Edge declarations must be inside a flow { } block (or the chain silently fails to parse)
Each edge must be its own line.
// Correct
flow {
root -> validate
validate -> process
process -> notify
}Conditional routing without a switch node
// Incorrect — conditional edges do not exist in the DSL
flow {
root -> process
process -> notify if process.output.shouldNotify
process -> skip if !process.output.shouldNotify
}Conditional routing requires a switch node with a router function and labeled edges.
// Correct
node route {
type: switch
label: "Route"
cases: ["notify", "skip"]
router: @ts {
if (context.nodes.process.output.shouldNotify) return "notify"
return "skip"
}
}
flow {
root -> process
process -> route
route -["notify"]-> notify
route -["skip"]-> skip
}Edges outside the flow block
// Incorrect
workflow my_workflow {
label: "My Workflow"
root { type: code label: "Entry" code: @ts { return {} } }
node step { type: code label: "Step" code: @ts { return {} } }
root -> step
}Parser error: Edge declarations must be inside a flow { } block
// Correct
workflow my_workflow {
label: "My Workflow"
root { type: code label: "Entry" code: @ts { return {} } }
node step { type: code label: "Step" code: @ts { return {} } }
flow {
root -> step
}
}Resources and triggers
Using an array for secrets:
// Incorrect
node call_api {
type: http
url: @ts { return "https://api.example.com" }
secrets: [API_KEY]
}Parser error: Expected { after secrets:
The secrets: field is an object literal mapping secret block names to arrays of var names.
// Correct
node call_api {
type: http
url: @ts { return "https://api.example.com" }
secrets: {
my_creds: [API_KEY]
}
}Using process.env instead of context.secrets
// Incorrect
headers: @ts { return { Authorization: "Bearer " + process.env.API_KEY } }Code nodes run in a sandbox where process.env is an empty object. process.env.API_KEY returns undefined with no error, not your secret. Declare secrets with a secret block and bind them on the node.
// Correct
secret my_creds {
vars: [API_KEY]
}
node call_api {
type: http
url: @ts { return "https://api.example.com" }
headers: @ts { return { Authorization: "Bearer " + context.secrets.my_creds.API_KEY } }
secrets: {
my_creds: [API_KEY]
}
}Multiple bindings in one trigger block
// Incorrect
trigger on_forms {
form:contact -> process_contact
form:support -> process_support
}Each trigger block accepts exactly one binding line.
// Correct
trigger on_contact {
form:contact -> process_contact
}
trigger on_support {
form:support -> process_support
}Inventing trigger types
// Incorrect
trigger on_event {
agent:my_agent -> my_workflow
}Only three resource types are valid in trigger bindings: form, webhook, and schedule.
// Correct
trigger on_event {
webhook:my_webhook -> my_workflow
}Hyphenated resource names
// Incorrect
form contact-form {
label: "Contact"
}Validator error: Form name: Name must contain only letters, numbers, and underscores
All resource names (forms, workflows, nodes, streams, secrets, auth blocks, postgres blocks, triggers) must match ^[a-zA-Z0-9_]+$.
// Correct
form contact_form {
label: "Contact"
}This applies to every block, including app.
Quotes or colons inside an app block
// Incorrect
app "client-portal" {
description: "Customer portal"
expose {
agent: triage
}
}Parser errors: App name must be a bare identifier, not a quoted string, then (with the name fixed) app description must be a quoted string and Expected a name after agent.
The name is a bare identifier like every other block. But the app block is the one place in the language where key: value is wrong: every field binds its value with a space.
// Correct
app client_portal {
description "Customer portal"
expose {
agent triage
}
}Every other top-level block keeps its key: value syntax. Don't carry the space-separated style out of app.
Streams
Missing schema on a stream version
schema, condition, and prepare live inside a versions: entry, not at the top level of the block. Putting them at the top level errors: top-level "schema" is invalid on stream blocks — use versions { v1 { schema, condition?, prepare } }.
// Incorrect — version v1 has no schema
stream my_stream {
label: "My stream"
workflow: my_workflow
version: v1
versions: {
v1 {
prepare: @ts {
return { message: context.output.root.message }
}
}
}
}Every version requires a schema. The validator rejects a version without it: Stream "my_stream" version "v1" has no schema; add schema: @json { … } or schema: <name>.
// Correct
stream my_stream {
label: "My stream"
workflow: my_workflow
version: v1
versions: {
v1 {
schema: @json {
{ "type": "object", "required": ["message"], "properties": { "message": { "type": "string" } } }
}
prepare: @ts {
return { message: context.output.root.message }
}
}
}
}Expecting stream data in your own external Postgres
Stream rows live in a platform-managed schema (one table per stream version, e.g. project_<uuid>.stream_<name>_<version>), not in the external databases you declare with postgres blocks. Do not write type: postgres SQL expecting stream rows to appear in your own database. Read stream data with a type: stream node instead.
Missing prepare on a stream version
prepare is required on every version. Each version must declare what shape to write. Without prepare, the validator rejects that version.
Removed constructs
The persistence { } block
// Incorrect
workflow submissions {
label: "Submissions"
persistence {
enabled: true
condition: @ts { return true }
}
root {
type: code
label: "Entry"
code: @ts { return context.nodes.root.input }
}
}Parser error: persistence { } blocks have been removed — use a top-level stream block instead
Use a top-level stream { } block to persist workflow output.
// Correct
workflow submissions {
label: "Submissions"
root {
type: code
label: "Entry"
outputSchema: @json {
{ "type": "object", "properties": { "message": { "type": "string" } } }
}
code: @ts { return context.nodes.root.input }
}
}
stream submission_log {
label: "Submission log"
workflow: submissions
version: v1
versions: {
v1 {
schema: @json {
{ "type": "object", "properties": { "message": { "type": "string" } } }
}
condition: @ts { return true }
prepare: @ts {
return { message: context.output.root.message }
}
}
}
}query and querySql on stream nodes
// Incorrect
node recent {
type: stream
stream: "submissions"
query: @sql { SELECT * FROM {{table}} LIMIT 10 }
}Validator error: querySql and query are no longer supported on stream nodes; use filter (@ts returning a filter object)
// Correct
node recent {
type: stream
label: "Recent submissions"
stream: submissions
version: v1
filter: @ts {
return { score: { gte: 50 } }
}
}Note: the stream: field takes a bare identifier, not a quoted string.
Parser pitfalls (silent drops)
These patterns cause the parser to silently stop processing the rest of the file. swirls doctor may report success but with fewer workflows than you defined.
Stray characters at the DSL level
The lexer recognizes a fixed character set at DSL token positions. Any other character outside a comment, string literal, or fenced block stops tokenization, and everything after it is silently dropped.
// Incorrect — stray Unicode between declarations
workflow a { label: "A" root { type: code label: "Entry" code: @ts { return {} } } }
→
workflow b { label: "B" root { type: code label: "Entry" code: @ts { return {} } } }Workflow b is silently dropped.
Unicode is safe inside comments, string literals, and @ts / @json / @sql bodies. Box-drawing characters, arrows, and em dashes in // or /* */ comments parse fine. The hazard is only a character the lexer does not recognize at a DSL token position: between declarations, between fields, or in place of a value.
Hyphenated header keys in object literals
// Incorrect — causes silent drop
headers: { Content-Type: "application/json" }The lexer reads Content, hits the stray - it cannot tokenize, and stops. Everything to end-of-file is silently dropped. Quoting the key ("Content-Type":) does not help: DSL object keys must be bare identifiers, so the headers parse to an empty object instead.
// Correct — use a @ts block
headers: @ts {
return { "Content-Type": "application/json" }
}HTTP nodes also accept a top-level auth block for Authorization and API key headers, which avoids the problem entirely.
Detecting silent drops
When swirls doctor reports fewer workflows, forms, or triggers than you defined, a parser bug is dropping content.
swirls doctorCompare the counts in the output to what you wrote. If they differ, the problem is always in or before the first missing item.
To isolate it: comment out half the file, run swirls doctor, repeat. The bug is in whichever half causes the missing count.
See Syntax for safe patterns, and How execution works for the broader execution model.