n8n is a visual workflow editor. No coding required to build automations, but you can add custom code.
Core Concepts
Workflow
A workflow is a series of connected nodes that execute in sequence.
βββββββββββββββ
β Trigger β (listens for event)
ββββββββ¬βββββββ
β
βΌ
ββββββββββββββββββββ
β Transform β (modify data)
ββββββββ¬ββββββββββββ
β
βΌ
ββββββββββββββββββββ
β AI Processing β (call Ollama/Claude)
ββββββββ¬ββββββββββββ
β
βΌ
ββββββββββββββββββββ
β Output β (save/send result)
ββββββββββββββββββββ
Node
A node is a unit of work. Types:
- Trigger: Starts the workflow (webhook, schedule, file change)
- Action: Does something (call API, send email, query database)
- Control: Logic (if/else, loop, wait)
- Transform: Modify data (set variables, map, format)
Connection
Edges between nodes pass data forward.
Node A output β Node B input
Data flows as JSON objects.
Triggers
A trigger starts the workflow.
Webhook Trigger
Listen for HTTP requests.
Trigger: Webhook
ββ HTTP Method: POST
ββ Path: /chat
ββ Listen for requests at: http://your-server:5678/webhook/chat
Activate workflow to generate the webhook URL.
Example payload:
{
"message": "What is Docker?"
}
Schedule Trigger
Run on a timer.
Trigger: Schedule
ββ Trigger type: Every hours
ββ Interval: 1
ββ Runs every hour
Cron syntax for complex schedules:
0 9 * * * = Every day at 9:00 AM
0 */4 * * * = Every 4 hours
0 0 1 * * = First of every month
File Trigger
React to file changes.
Trigger: Watch Directory
ββ Path: /uploads/
ββ Trigger on: New file
ββ Process each new file
Nodes: Structure & Execution
Node Anatomy
βββββββββββββββββββββββββ
β Node Header β
βββββββββββββββββββββββββ€
β Node Type β
βββββββββββββββββββββββββ€
β Input Fields β
β β’ Name β
β β’ Value β
βββββββββββββββββββββββββ€
β Output Preview β
β { "result": "..." } β
βββββββββββββββββββββββββ
Common Nodes
| Node | Purpose | Input | Output |
|---|---|---|---|
| Set | Assign variables | Key-value pairs | Object with keys |
| HTTP Request | Call API | URL, method, body | Response JSON |
| Ollama | LLM inference | Prompt, model | Generated text |
| If | Conditional logic | Condition | True/false branch |
| Loop | Repeat over array | Array items | Each item |
| Code | JavaScript code | Input object | Transformed output |
| Database | Query database | Query, values | Result rows |
| Send Email | Send email | To, subject, body | Status |
Data & Expressions
Basic Expression
Reference data from previous nodes.
{{ $json.message }} # From input payload
{{ $json.user.name }} # Nested property
{{ $json.0.id }} # Array element
{{ $now.format('YYYY-MM-DD') }} # Current date
Transform Expression (= Prefix)
Perform calculations. CRITICAL in n8n 2.x: Use = prefix!
# WRONG (n8n 2.x):
"prompt": "{{ $json.message }}"
# Result: literal string "{{ $json.message }}"
# CORRECT (n8n 2.x):
"prompt": "={{ $json.message }}"
# Result: actual message content
Why? Without =, n8n treats it as plain text. With =, n8n evaluates as JavaScript.
Expression Examples
# Arithmetic
={{ $json.count + 1 }} # Add 1
={{ $json.price * 0.9 }} # Apply discount
# String
={{ $json.name.toUpperCase() }} # Uppercase
={{ $json.text.slice(0, 100) }} # First 100 chars
={{ $json.items.join(', ') }} # Join array
# Array
={{ $json.items.length }} # Array length
={{ $json.items.filter(x => x.active) }} # Filter
={{ $json.items.map(x => x.name) }} # Transform
# Conditional
={{ $json.status === 'pending' ? 'Waiting' : 'Done' }}
# Date (Luxon library)
={{ $now.toISO() }} # ISO 8601
={{ $now.minus({days: 7}).toISODate() }} # 7 days ago
={{ $now.toFormat('yyyy-MM-dd HH:mm') }} # Custom format
Date Formatting (Luxon, NOT Moment.js)
CRITICAL in n8n 2.x: Use Luxon, not Moment!
# WRONG:
$now.format('YYYY-MM-DD') # Outputs: YYYY-03-DD (literal!)
# CORRECT:
$now.format('yyyy-MM-dd') # Outputs: 2026-03-21
Luxon key differences:
yyyy= 4-digit yearMM= 2-digit monthdd= 2-digit dayHH= 24-hour formathh= 12-hour format
AI Nodes
Ollama Node
Run local LLM.
Ollama
ββ Base URL: http://ollama:11434
ββ Model: mistral
ββ Prompt: ={{ $json.question }}
ββ Temperature: 0.7
ββ Output: $json.response
Claude API Node (via HTTP)
Call Claude via REST API.
HTTP Request
ββ Method: POST
ββ URL: https://api.anthropic.com/v1/messages
ββ Headers:
β ββ x-api-key: ={{ $env.ANTHROPIC_API_KEY }}
β ββ content-type: application/json
ββ Body: ={
"model": "claude-opus",
"messages": [{"role": "user", "content": "{{ $json.question }}"}],
"max_tokens": 1024
}
OpenAI Node
Call GPT API.
OpenAI Completion
ββ API Key: ={{ $env.OPENAI_API_KEY }}
ββ Model: gpt-3.5-turbo
ββ Prompt: ={{ $json.question }}
ββ Output: $json.choices.0.message.content
Control Flow
If Node
Conditional branching.
If Node
ββ Condition: {{ $json.status }} === 'error'
β
ββ TRUE branch:
β ββ Send error email
β
ββ FALSE branch:
ββ Continue processing
Loop Node (Item Lists)
Process array of items.
Item Lists
ββ Mode: Split Out Items
ββ Array: {{ $json.items }}
β
ββ Creates multiple executions, one per item
ββ Each execution processes $json.item
Example:
Webhook receives: [{"name": "Alice"}, {"name": "Bob"}]
β
Item Lists splits into 2 executions
β
Execution 1: Send email to Alice
Execution 2: Send email to Bob
Wait Node
Pause execution.
Wait
ββ Amount: 5
ββ Unit: seconds
ββ (waits 5 seconds, then continues)
Or wait for condition:
Wait
ββ Mode: Until date/time
ββ Target date: {{ $json.scheduled_time }}
Set Node (Variables)
Define working data.
Set Node
ββ Keys:
β ββ user_name: ={{ $json.user.name }}
β ββ is_admin: ={{ $json.role === 'admin' }}
β ββ timestamp: ={{ $now.toISO() }}
β ββ message: ={{ 'User ' + $json.user.name + ' logged in' }}
β
ββ Output object:
{
"user_name": "Alice",
"is_admin": true,
"timestamp": "2026-03-21T10:15:00Z",
"message": "User Alice logged in"
}
Error Handling
Workflows can fail. Handle gracefully.
Try-Catch Pattern
Node A (risky)
ββ On error: Continue (or stop)
β
ββ If error:
ββ Error Handler Node
ββ Send error email
ββ Log to database
ββ Notify user
Conditional Error Handling
HTTP Request
ββ On error: Continue
β
If Node:
ββ Condition: {{ $json.statusCode === 404 }}
ββ TRUE: Handle not found
ββ FALSE: Handle other errors
Example: Complete Workflow
"Classify Email & Route"
Trigger: Webhook receives email
β
Set: Parse email fields
ββ from: {{ $json.from }}
ββ subject: {{ $json.subject }}
ββ body: {{ $json.body }}
β
Ollama: Classify content
ββ Prompt: "Classify as: bug, feature, question\n{{ $json.body }}"
ββ Output: category
β
If: Check category
ββ Bug β Send to GitHub (HTTP Request)
ββ Feature β Save to Airtable (Airtable node)
ββ Question β Send to support team (Email)
β
Send Email: Confirmation to sender
ββ "Received as {{ $json.category }}"
Best Practices
-
Use variable names, not nested paths
# Good Set: user_id = {{ $json.user.id }} Later: {{ $json.user_id }} # Clear # Confusing {{ $json.data.response.user.id.value }} -
Add notes to complex nodes
- Click node β Edit Fields β Add description
- "This Ollama call extracts entities from user input"
-
Test with sample data
- "Test" button β provide sample JSON
- Verify output before activating
-
Monitor workflows
- Executions tab shows run history
- Check logs for errors
- Set error handlers
-
Use environment variables for secrets
# .env file ANTHROPIC_API_KEY=sk-... OLLAMA_URL=http://ollama:11434 # In workflow: {{ $env.ANTHROPIC_API_KEY }}
Checklist
- Understand nodes and connections
- Create first workflow (webhook β ollama β response)
- Test = prefix in expressions
- Use correct Luxon date format (yyyy-MM-dd)
- Set up If/branching
- Add error handling
- Use Set node for variables
- Activate and test with real data
- Monitor execution logs
- Document complex workflows
